Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
51 changes: 41 additions & 10 deletions src/components/slide/script/ScriptBoxContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,59 @@
* 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-8 items-center gap-1.5 rounded-md border border-gray-200 bg-white/95 px-2.5 py-1 text-gray-500 shadow-sm transition-colors hover:bg-gray-100 hover:text-gray-700 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main"
>
<span className="text-xs leading-4">{estimatedDuration}</span>
<IconSetting className="size-3.5 shrink-0" 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
134 changes: 134 additions & 0 deletions src/components/slide/script/ScriptReadingSpeedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';

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 activeProjectId = isOpen ? (projectId ?? '') : '';
const { data: projectScripts } = useProjectScripts(activeProjectId);
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={`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={`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,
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 = SCRIPT_READING_SPEED_OPTIONS.find((option) => option.id === presetId);
if (!selectedOption) return;
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
Loading