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
7 changes: 7 additions & 0 deletions src/assets/icons/icon-setting.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 43 additions & 10 deletions src/components/slide/script/ScriptBoxContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,61 @@
* Zustand store를 통해 대본을 읽고 업데이트하며,
* debounce로 자동저장됩니다.
*/
import { useAutoSaveScript, useSlideActions, useSlideScript } from '@/hooks';
import { useMemo, useState } from 'react';

import IconSetting from '@/assets/icons/icon-setting.svg?react';
import { useAutoSaveScript, useScriptReadingSpeed, useSlideActions, useSlideScript } from '@/hooks';
import { estimateScriptDurationSeconds, formatScriptDuration } from '@/utils/scriptDuration';

import ScriptReadingSpeedModal from './ScriptReadingSpeedModal';

export default function ScriptBoxContent() {
const [isSpeedModalOpen, setIsSpeedModalOpen] = useState(false);
const script = useSlideScript();
const { updateScript } = useSlideActions();
const { autoSave, flushSave } = useAutoSaveScript();
const { selectedSpeed } = useScriptReadingSpeed();

const estimatedDuration = useMemo(() => {
const durationSeconds = estimateScriptDurationSeconds(script, selectedSpeed);
return formatScriptDuration(durationSeconds);
}, [script, selectedSpeed]);

const handleChange = (value: string) => {
updateScript(value);
autoSave(value);
};

return (
<div className="h-full bg-white px-4 pt-3 pb-6">
<textarea
value={script}
onChange={(e) => handleChange(e.target.value)}
onBlur={flushSave}
placeholder="슬라이드 대본을 입력하세요..."
aria-label="슬라이드 대본"
className="h-full w-full resize-none border-none bg-transparent text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600 overflow-y-auto"
<>
<div className="relative h-full bg-white px-4 pt-3 pb-6">
<textarea
value={script}
onChange={(e) => handleChange(e.target.value)}
onBlur={flushSave}
placeholder="슬라이드 대본을 입력하세요..."
aria-label="슬라이드 대본"
className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 pr-28 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600"
/>

<div className="absolute bottom-3 right-4">
<button
type="button"
onClick={() => setIsSpeedModalOpen(true)}
aria-label={`읽기 속도 설정 열기 (현재 예상 시간 ${estimatedDuration})`}
className="inline-flex min-h-9 items-center gap-2 rounded-md bg-white px-3 py-1.5 text-gray-700 transition-colors hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main"
>
<span aria-live="polite" aria-atomic="true" className="text-sm font-semibold leading-4">
{estimatedDuration}
</span>
<IconSetting className="size-4 shrink-0 text-gray-700" aria-hidden="true" />
</button>
</div>
</div>
<ScriptReadingSpeedModal
isOpen={isSpeedModalOpen}
onClose={() => setIsSpeedModalOpen(false)}
/>
</div>
</>
);
}
17 changes: 17 additions & 0 deletions src/components/slide/script/ScriptBulkEditModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useMemo } from 'react';
import type { ChangeEvent, RefObject } from 'react';

import UploadIcon from '@/assets/icons/icon-upload.svg?react';
import { Modal, SlideImage } from '@/components/common';
import type { ScriptBulkEditPreviewItem } from '@/hooks/useScriptBulkEdit';
import { useScriptReadingSpeed } from '@/hooks/useScriptReadingSpeed';
import { estimateScriptsDurationSeconds, formatScriptDuration } from '@/utils/scriptDuration';

interface ScriptBulkEditModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -31,6 +34,15 @@ function ScriptBulkEditModal({
onFileChange,
onScriptChange,
}: ScriptBulkEditModalProps) {
const { selectedSpeed } = useScriptReadingSpeed();
const totalDuration = useMemo(() => {
const durationSeconds = estimateScriptsDurationSeconds(
previewItems.map((item) => item.script),
selectedSpeed,
);
return formatScriptDuration(durationSeconds);
}, [previewItems, selectedSpeed]);

return (
<Modal
isOpen={isOpen}
Expand Down Expand Up @@ -62,6 +74,11 @@ function ScriptBulkEditModal({
<span className="font-semibold text-gray-800">{selectedFileName}</span>
</p>
) : null}
<p className="text-sm text-gray-600">
전체 예상 읽기 시간:{' '}
<span className="font-semibold text-gray-800">{totalDuration}</span>{' '}
<span>(분당 {selectedSpeed}자 기준)</span>
</p>
</div>

<button
Expand Down
141 changes: 141 additions & 0 deletions src/components/slide/script/ScriptReadingSpeedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';

import clsx from 'clsx';

import { Dropdown, Modal } from '@/components/common';
import type { DropdownItem } from '@/components/common/Dropdown';
import { useProjectScripts, useScriptReadingSpeed, useSlideId, useSlideScript } from '@/hooks';
import {
SCRIPT_READING_SPEED_MAX,
SCRIPT_READING_SPEED_MIN,
estimateScriptsDurationSeconds,
formatScriptDuration,
} from '@/utils/scriptDuration';

interface ScriptReadingSpeedModalProps {
isOpen: boolean;
onClose: () => void;
}

export default function ScriptReadingSpeedModal({ isOpen, onClose }: ScriptReadingSpeedModalProps) {
const { projectId } = useParams<{ projectId: string }>();
const { data: projectScripts } = useProjectScripts(projectId ?? '');
const slideId = useSlideId();
const script = useSlideScript();
const { speedOptions, selectedSpeed, selectedPreset, setSelectedSpeed } = useScriptReadingSpeed();

const speedDropdownItems: DropdownItem[] = speedOptions.map((option) => ({
id: option.id,
label: option.label,
selected: selectedSpeed === option.charsPerMinute,
onClick: () => setSelectedSpeed(option.charsPerMinute),
}));

const scripts = useMemo(() => {
const scriptMap = new Map<string, string>();

(projectScripts?.scripts ?? []).forEach((scriptItem) => {
scriptMap.set(scriptItem.slideId, scriptItem.scriptText ?? '');
});

if (slideId) {
scriptMap.set(slideId, script);
} else if (scriptMap.size < 1 && script.length > 0) {
scriptMap.set('current', script);
}

return Array.from(scriptMap.values());
}, [projectScripts, slideId, script]);

const totalDurationSeconds = useMemo(
() => estimateScriptsDurationSeconds(scripts, selectedSpeed),
[scripts, selectedSpeed],
);
const totalDuration = useMemo(
() => formatScriptDuration(totalDurationSeconds),
[totalDurationSeconds],
);
const presetLabel = selectedPreset?.label ?? '직접 설정';

return (
<Modal isOpen={isOpen} onClose={onClose} title="읽기 속도 설정" size="sm">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-body-m-bold text-gray-800">프리셋</span>
<Dropdown
trigger={({ isOpen }) => (
<button
type="button"
className={clsx(
'flex w-full items-center justify-between rounded-lg border border-gray-200 bg-white px-5 py-3',
isOpen && 'border-gray-400',
)}
>
<span className="text-body-m-bold text-gray-800">{presetLabel}</span>
<svg
className={clsx(
'h-4 w-4 text-gray-600 transition-transform',
isOpen && 'rotate-180',
)}
viewBox="0 0 24 24"
fill="none"
aria-hidden
>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
items={speedDropdownItems}
className="w-full"
menuClassName="w-full"
ariaLabel="읽기 속도 프리셋 선택"
/>
</div>

<div className="flex flex-col gap-2">
<label htmlFor="script-reading-speed-slider" className="text-body-m-bold text-gray-800">
읽기 속도: 분당 {selectedSpeed}자
</label>
<input
id="script-reading-speed-slider"
type="range"
min={SCRIPT_READING_SPEED_MIN}
max={SCRIPT_READING_SPEED_MAX}
step={1}
value={selectedSpeed}
onChange={(event) => setSelectedSpeed(Number(event.target.value))}
className="h-2 w-full cursor-pointer accent-main"
/>
<div className="flex items-center justify-between text-caption text-gray-600">
<span>매우 느리게</span>
<span>매우 빠르게</span>
</div>
</div>

<div className="rounded-lg bg-gray-100 px-4 py-3">
<p className="text-sm text-gray-600">전체 대본 예상 읽기 시간</p>
<p className="text-body-m-bold text-gray-800">{totalDuration}</p>
</div>

<p className="text-sm leading-5 text-gray-600">
예상 시간은 전체 대본의 공백을 제외한 글자 수를 기준으로 계산됩니다.
</p>

<button
type="button"
onClick={onClose}
className="w-full rounded-md bg-main py-3 font-bold text-white transition-colors hover:bg-main-variant2"
>
확인
</button>
</div>
</Modal>
);
}
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './useAutoSaveScript';
export * from './useDebounce';
export * from './useHotkey';
export * from './useMediaQuery';
export * from './useScriptReadingSpeed';
export * from './useSlideNavigation';
export * from './useSlideSelectors';
export * from './useSlideCommentsActions';
Expand Down
36 changes: 36 additions & 0 deletions src/hooks/useScriptReadingSpeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useMemo } from 'react';

import { useScriptReadingSpeedStore } from '@/stores/scriptReadingSpeedStore';
import {
SCRIPT_READING_SPEED_OPTIONS,
type ScriptReadingSpeedPresetId,
getScriptReadingSpeedOption,
getScriptReadingSpeedPreset,
normalizeScriptReadingSpeed,
} from '@/utils/scriptDuration';

export function useScriptReadingSpeed() {
const speed = useScriptReadingSpeedStore((state) => state.speed);
const setSpeed = useScriptReadingSpeedStore((state) => state.setSpeed);

const selectedSpeed = useMemo(() => normalizeScriptReadingSpeed(speed), [speed]);
const selectedPreset = useMemo(() => getScriptReadingSpeedPreset(selectedSpeed), [selectedSpeed]);
const selectedSpeedLabel = useMemo(
() => selectedPreset?.label ?? `직접 설정 (분당 ${selectedSpeed}자)`,
[selectedPreset, selectedSpeed],
);

const setSelectedPresetId = (presetId: ScriptReadingSpeedPresetId) => {
const selectedOption = getScriptReadingSpeedOption(presetId);
setSpeed(selectedOption.charsPerMinute);
};

return {
speedOptions: SCRIPT_READING_SPEED_OPTIONS,
selectedSpeed,
selectedPreset,
selectedSpeedLabel,
setSelectedSpeed: setSpeed,
setSelectedPresetId,
};
}
35 changes: 35 additions & 0 deletions src/stores/scriptReadingSpeedStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

import {
DEFAULT_SCRIPT_READING_SPEED,
SCRIPT_READING_SPEED_STORAGE_KEY,
normalizeScriptReadingSpeed,
} from '@/utils/scriptDuration';

interface ScriptReadingSpeedState {
speed: number;
setSpeed: (nextSpeed: number) => void;
}

export const useScriptReadingSpeedStore = create<ScriptReadingSpeedState>()(
persist(
(set) => ({
speed: DEFAULT_SCRIPT_READING_SPEED,
setSpeed: (nextSpeed) => {
set({ speed: normalizeScriptReadingSpeed(nextSpeed) });
},
}),
{
name: SCRIPT_READING_SPEED_STORAGE_KEY,
partialize: (state) => ({ speed: state.speed }),
onRehydrateStorage: () => (state) => {
if (!state) return;
const normalizedSpeed = normalizeScriptReadingSpeed(state.speed);
if (state.speed !== normalizedSpeed) {
state.setSpeed(normalizedSpeed);
}
},
},
),
);
Loading