@ihabdevteam/core 0.133.0 → 0.134.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/dist/components.cjs.js +176 -176
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +2 -2
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +8 -2
- package/dist/packages/core/src/components/Breadcrumb.js +5 -1
- package/dist/packages/core/src/components/Dialog.js +32 -9
- package/dist/packages/core/src/components/Header.css +2 -0
- package/dist/packages/core/src/components/Header.js +4 -1
- package/dist/packages/core/src/components/LandingHeader.js +18 -1
- package/dist/packages/core/src/components/Pagination.js +5 -2
- package/dist/packages/core/src/components/Panel.d.ts +1 -1
- package/dist/packages/core/src/components/Panel.js +4 -1
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
- package/dist/packages/core/src/components/PaymentPlan.js +5 -3
- package/dist/packages/core/src/components/PromoBanner.js +1 -1
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/SoundCompass.js +10 -1
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +7 -2
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
- package/dist/packages/core/src/i18n/locales/en.json +11 -0
- package/dist/packages/core/src/i18n/locales/ko.json +11 -0
- package/dist/packages/core/src/utils/format.js +17 -1
- package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
- package/dist/packages/core/src/utils/pressable.js +4 -0
- package/dist/packages/core/src/utils/savedWordTables.js +2 -1
- package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
- package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +27 -13
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -1308,7 +1308,7 @@ export declare interface ContrastFilterPanelProps {
|
|
|
1308
1308
|
}
|
|
1309
1309
|
|
|
1310
1310
|
/** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
|
|
1311
|
-
export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
|
|
1311
|
+
export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
|
|
1312
1312
|
|
|
1313
1313
|
/**
|
|
1314
1314
|
* 텍스트를 클립보드에 복사. 성공 여부를 boolean으로 반환.
|
|
@@ -1369,7 +1369,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1369
1369
|
*
|
|
1370
1370
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1371
1371
|
*/
|
|
1372
|
-
export declare const CORE_VERSION = "0.
|
|
1372
|
+
export declare const CORE_VERSION = "0.134.0";
|
|
1373
1373
|
|
|
1374
1374
|
/** @public */
|
|
1375
1375
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -3794,6 +3794,12 @@ export declare interface PaymentPlanTier {
|
|
|
3794
3794
|
* 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
|
|
3795
3795
|
*/
|
|
3796
3796
|
openPromo?: boolean;
|
|
3797
|
+
/**
|
|
3798
|
+
* **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
|
|
3799
|
+
* 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
|
|
3800
|
+
* 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
|
|
3801
|
+
*/
|
|
3802
|
+
onceOnly?: boolean;
|
|
3797
3803
|
[k: string]: unknown;
|
|
3798
3804
|
}
|
|
3799
3805
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
2
3
|
import './Breadcrumb.css';
|
|
3
4
|
import Icon from './Icon';
|
|
4
5
|
/** @public */
|
|
5
6
|
export default function Breadcrumb({ items, className = '' }) {
|
|
6
|
-
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
return (
|
|
9
|
+
/* 영어로 박혀 있어 한국어 화면에서도 낭독기가 "breadcrumb" 이라고 읽었다 — 사전을 탄다 */
|
|
10
|
+
_jsx("nav", { className: `breadcrumb ${className}`.trim(), "aria-label": t('breadcrumb.ariaLabel', '이동 경로'), children: _jsx("ol", { className: "breadcrumb__list", children: items.map((item, index) => {
|
|
7
11
|
const isLast = index === items.length - 1;
|
|
8
12
|
return (_jsxs("li", { className: "breadcrumb__item", children: [isLast ? (_jsx("span", { className: "breadcrumb__current body-3", "aria-current": "page", children: item.label })) : (_jsx("button", { type: "button", className: "breadcrumb__link body-3", onClick: item.onClick, children: item.label })), !isLast && (_jsx(Icon, { icon: "chevron-right", size: 14, className: "breadcrumb__separator" }))] }, index));
|
|
9
13
|
}) }) }));
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useRef } from 'react';
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import { createPortal } from 'react-dom';
|
|
6
6
|
import { cx } from '../utils/cx';
|
|
7
|
+
import { lockBodyScroll } from '../hooks/bodyScrollLock';
|
|
7
8
|
import { pushEsc } from '../hooks/escStack';
|
|
8
9
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
|
9
10
|
import './Dialog.css';
|
|
@@ -39,12 +40,18 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
|
|
|
39
40
|
if (!open)
|
|
40
41
|
return;
|
|
41
42
|
const 돌아갈곳 = document.activeElement;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/* 스크롤 잠금은 **공유 카운터**(Popup·TrainingPagePopup 과 같은 것)로 — 전에는 body.style.overflow 를 제 손으로
|
|
44
|
+
기억했다 되돌려, Dialog 위에 Popup 을 열고 Dialog 를 먼저 닫으면 Popup 이 떠 있는데 스크롤이 풀리고, 이어 Popup 을
|
|
45
|
+
닫으면 overflow:hidden 으로 영영 굳었다. */
|
|
46
|
+
const 풀기 = lockBodyScroll();
|
|
47
|
+
const 판 = surfaceRef.current;
|
|
48
|
+
판?.focus();
|
|
45
49
|
return () => {
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
풀기();
|
|
51
|
+
// 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 되돌린다 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
|
|
52
|
+
const 지금 = document.activeElement;
|
|
53
|
+
if (!지금 || 지금 === document.body || (판 && 판.contains(지금)))
|
|
54
|
+
돌아갈곳?.focus?.();
|
|
48
55
|
};
|
|
49
56
|
}, [open]);
|
|
50
57
|
/* ESC 는 **공유 스택**에 맡긴다 — 예전에는 window 에서 직접 들었다.
|
|
@@ -53,11 +60,21 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
|
|
|
53
60
|
**아래 팝업이 닫히고 이 확인창만 남는** 꼴이다 — 닫으려던 것은 그대로 있고
|
|
54
61
|
뒤에 있던 화면이 사라진다.
|
|
55
62
|
스택에 올리면 맨 위 하나만 닫힌다(그것이 이 창이다). */
|
|
63
|
+
/* 스택에는 **열릴 때 한 번** 올린다 — handleClose 가 바뀔 때마다(인라인 onClose) 다시 올리면 맨 위로 끼어들어, 위에
|
|
64
|
+
팝업이 떠 있어도 Esc 가 이 창을 닫았다. closeOnEscape={false} 라도 올려서 Esc 를 **삼킨다** — 안 올리면 이 창
|
|
65
|
+
아래에 가려진 팝업이 닫혔다(모달은 제 아래를 지킨다). */
|
|
66
|
+
const closeRef = useRef(handleClose);
|
|
67
|
+
const escRef = useRef(closeOnEscape);
|
|
68
|
+
useEffect(() => { closeRef.current = handleClose; escRef.current = closeOnEscape; });
|
|
56
69
|
useEffect(() => {
|
|
57
|
-
if (!open
|
|
70
|
+
if (!open)
|
|
58
71
|
return;
|
|
59
|
-
return pushEsc(
|
|
60
|
-
|
|
72
|
+
return pushEsc(() => { if (escRef.current)
|
|
73
|
+
closeRef.current(); });
|
|
74
|
+
}, [open]);
|
|
75
|
+
/* 바탕 누르기 — 누른 곳과 뗀 곳이 **둘 다 바탕일 때만** 닫는다(Popup 0.128.0 과 같다). 판 안에서 글자를 긁어
|
|
76
|
+
고르다 바깥에서 놓으면 click 이 공통 조상(바탕)에 떨어져 창이 닫혔다. */
|
|
77
|
+
const 바탕에서누름 = useRef(false);
|
|
61
78
|
/* Tab 을 이 판 안에 가둔다 — `aria-modal="true"` 는 **낭독기에게만** 통한다.
|
|
62
79
|
눈으로 보며 Tab 을 누르는 사람은 그 표시를 못 보므로, 가둠이 없으면 마지막
|
|
63
80
|
단추 다음에서 **뒤 화면**으로 빠져나간다. 실제로 그랬다: 놀이터
|
|
@@ -67,5 +84,11 @@ export default function Dialog({ open, onClose, ariaLabel, title, children, size
|
|
|
67
84
|
useFocusTrap(surfaceRef, open);
|
|
68
85
|
if (!open || typeof document === 'undefined')
|
|
69
86
|
return null;
|
|
70
|
-
return createPortal(_jsx("div", { className: cx('dialog', className),
|
|
87
|
+
return createPortal(_jsx("div", { className: cx('dialog', className), onPointerDown: closeOnBackdrop ? (e) => { 바탕에서누름.current = e.target === e.currentTarget; } : undefined, onMouseDown: (e) => { if (e.target === e.currentTarget)
|
|
88
|
+
e.preventDefault(); }, onClick: closeOnBackdrop ? (e) => {
|
|
89
|
+
const 눌렀나 = 바탕에서누름.current;
|
|
90
|
+
바탕에서누름.current = false;
|
|
91
|
+
if (눌렀나 && e.target === e.currentTarget)
|
|
92
|
+
handleClose();
|
|
93
|
+
} : undefined, children: _jsxs("div", { ref: surfaceRef, role: "dialog", "aria-modal": "true", "aria-label": title ? undefined : ariaLabel, "aria-labelledby": title ? titleId : undefined, tabIndex: -1, className: cx('dialog__surface', `dialog__surface--${size}`, contentClassName), onClick: (e) => e.stopPropagation(), children: [title ? (_jsx("div", { className: "dialog__header", children: _jsx("h2", { id: titleId, className: "dialog__title h4b", children: title }) })) : null, showClose ? (_jsx("button", { type: "button", className: "dialog__close", "aria-label": t('common.close', '닫기'), onClick: handleClose, children: _jsx(CloseIcon, {}) })) : null, _jsx("div", { className: "dialog__body", children: children })] }) }), document.body);
|
|
71
94
|
}
|
|
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
5
5
|
import { useMediaQuery } from '../hooks';
|
|
6
6
|
import BottomNav from './BottomNav';
|
|
7
7
|
import Button from './Button';
|
|
8
|
+
import Badge from './Badge';
|
|
8
9
|
import MenuTooltip from './Tooltip/MenuTooltip';
|
|
9
10
|
import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
|
|
10
11
|
import { createPortal } from 'react-dom';
|
|
@@ -112,7 +113,9 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
|
|
|
112
113
|
onClick: () => { it.onClick?.(); closeUtilMenu(); },
|
|
113
114
|
})),
|
|
114
115
|
}], [bottomMoreItems, activeKey, closeUtilMenu]);
|
|
115
|
-
return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel:
|
|
116
|
+
return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: onNotification && unreadCount && unreadCount > 0
|
|
117
|
+
? t('nav.badgeLabel', { label: t('header.menuOpen', '메뉴 열기'), count: unreadCount, defaultValue: '{{label}}, {{count}}개' })
|
|
118
|
+
: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), !!onNotification && !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true })), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
|
|
116
119
|
}
|
|
117
120
|
/**
|
|
118
121
|
* `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useId, useState } from 'react';
|
|
3
|
+
import { useEffect, useId, useState } from 'react';
|
|
4
|
+
import { pushEsc } from '../hooks/escStack';
|
|
4
5
|
import './LandingHeader.css';
|
|
5
6
|
/**
|
|
6
7
|
* 랜딩/마케팅 페이지용 상단 고정 헤더.
|
|
@@ -22,6 +23,22 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
|
|
|
22
23
|
// 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
|
|
23
24
|
// 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
|
|
24
25
|
const drawerId = useId();
|
|
26
|
+
/* 서랍이 열려 있으면 Esc 로 닫는다(공유 ESC 스택 — 맨 위 하나만). 넓은 화면(961px~, 서랍이 CSS 로 숨는 폭)으로 가면
|
|
27
|
+
닫는다 — 안 닫으면 open 이 남아 좁은 폭으로 돌아오는 순간 서랍이 다시 펼쳐졌다. */
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!open)
|
|
30
|
+
return;
|
|
31
|
+
const 풀기 = pushEsc(() => setOpen(false));
|
|
32
|
+
const mq = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(min-width: 961px)') : null;
|
|
33
|
+
const 넓어짐 = (e) => { if (e.matches)
|
|
34
|
+
setOpen(false); };
|
|
35
|
+
if (mq) {
|
|
36
|
+
if (mq.matches)
|
|
37
|
+
setOpen(false);
|
|
38
|
+
mq.addEventListener?.('change', 넓어짐);
|
|
39
|
+
}
|
|
40
|
+
return () => { 풀기(); mq?.removeEventListener?.('change', 넓어짐); };
|
|
41
|
+
}, [open]);
|
|
25
42
|
const innerClass = containerClassName
|
|
26
43
|
? `${containerClassName} landing-header__inner`
|
|
27
44
|
: 'landing-header__inner';
|
|
@@ -14,7 +14,10 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
|
|
|
14
14
|
const { t } = useTranslation();
|
|
15
15
|
const resolvedPrev = prevLabel ?? t('common.prevPage', '이전 페이지');
|
|
16
16
|
const resolvedNext = nextLabel ?? t('common.nextPage', '다음 페이지');
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/* 범위 밖 쪽(page > totalPages — 결과가 줄어든 직후)에서도 '이전' 이 범위 안으로 돌아오게 먼저 조인다.
|
|
18
|
+
전에는 page-1 이 여전히 totalPages 보다 커 눌러도 범위 밖에 머물렀다. */
|
|
19
|
+
const 마지막 = Math.max(1, totalPages);
|
|
20
|
+
const handlePrev = () => onChange(Math.max(1, Math.min(마지막, page - 1)));
|
|
21
|
+
const handleNext = () => onChange(Math.min(마지막, page + 1));
|
|
19
22
|
return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", children: [page, " / ", totalPages] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
|
|
20
23
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { cx } from '../utils/cx';
|
|
4
4
|
import { PanelRow } from './PanelRow';
|
|
5
|
+
import { Children } from 'react';
|
|
5
6
|
import Icon from './Icon';
|
|
6
7
|
import './Panel.css';
|
|
7
8
|
export default function Panel({ title, icon, disc, actions, size = 'medium', minHeight, maxWidth, className, scrollable, useBorder = true, useBg = true, useRowBorder = true, labelWidth, form = false, loading, empty, children }) {
|
|
@@ -15,7 +16,9 @@ export default function Panel({ title, icon, disc, actions, size = 'medium', min
|
|
|
15
16
|
style.maxWidth = typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth;
|
|
16
17
|
if (labelWidth !== undefined)
|
|
17
18
|
style['--panel-label-width'] = typeof labelWidth === 'number' ? `${labelWidth}px` : labelWidth;
|
|
18
|
-
return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(title != null || disc != null) && (_jsxs("div", { className: cx('panel__header', disc != null ? 'panel__header--with-disc' : ''), children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "panel__icon" })), _jsxs("div", { className: "panel__title-group", children: [title != null && (_jsx("h2", { className: `panel__title ${disc != null ? 'body-3b' : 'h5b'}`, children: title })), disc != null && _jsx("p", { className: "panel__disc caption-1", children: disc })] }), actions && _jsx("div", { className: "panel__actions", children: actions })] })), loading ? (_jsx(PanelRow, { variant: "loading" })
|
|
19
|
+
return (_jsxs("div", { className: cls, style: Object.keys(style).length ? style : undefined, children: [(title != null || disc != null) && (_jsxs("div", { className: cx('panel__header', disc != null ? 'panel__header--with-disc' : ''), children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "panel__icon" })), _jsxs("div", { className: "panel__title-group", children: [title != null && (_jsx("h2", { className: `panel__title ${disc != null ? 'body-3b' : 'h5b'}`, children: title })), disc != null && _jsx("p", { className: "panel__disc caption-1", children: disc })] }), actions && _jsx("div", { className: "panel__actions", children: actions })] })), loading ? (_jsx(PanelRow, { variant: "loading" })
|
|
20
|
+
/* 비었는지는 React 가 그리는 것으로 센다 — `{조건 && 행들}` 이 false 면 children 이 false 라 빈 상태 문구가 안 떴다 */
|
|
21
|
+
) : empty != null && Children.toArray(children).length === 0 ? (_jsx(PanelRow, { variant: "empty", children: empty })) : scrollable ? (_jsx("div", { className: "panel__body--scroll", children: children })) : children] }));
|
|
19
22
|
}
|
|
20
23
|
// ── PanelGrid ──────────────────────────────────────────────────────────────
|
|
21
24
|
/** @public */
|
|
@@ -24,6 +24,12 @@ function derivePlan(p) {
|
|
|
24
24
|
const nm = name.match(/(\d+\s*(?:년|개월|주|일))\s*(?:이용권)?\s*$/);
|
|
25
25
|
if (nm)
|
|
26
26
|
return nm[1].replace(/\s+/g, '');
|
|
27
|
+
/* 영어 이름('Open Special 30-Day Plan'·'1-Year Plan')에서도 일·년 기간을 뽑는다 — 한국어 꼴만 봐서 영어 화면(말귀 랜딩
|
|
28
|
+
요금 카드는 code 를 안 넘긴다)에서는 이름 전체가 큰 글씨로 찍히고 뒤에 등급 이름이 또 붙었다. 돌려주는 토큰은 위와 같은
|
|
29
|
+
'30일'·'1년' 꼴이다 — PassCard 가 숫자와 단위로 쪼개 단위를 passCard.unitDay·unitYear 로 번역한다. */
|
|
30
|
+
const en = name.match(/(\d+)\s*-?\s*(day|year)s?\b/i);
|
|
31
|
+
if (en)
|
|
32
|
+
return en[2].toLowerCase() === 'year' ? `${en[1]}년` : `${en[1]}일`;
|
|
27
33
|
return name || '이용권';
|
|
28
34
|
}
|
|
29
35
|
/**
|
|
@@ -20,6 +20,12 @@ export interface PaymentPlanTier {
|
|
|
20
20
|
* 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
|
|
21
21
|
*/
|
|
22
22
|
openPromo?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
|
|
25
|
+
* 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
|
|
26
|
+
* 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
|
|
27
|
+
*/
|
|
28
|
+
onceOnly?: boolean;
|
|
23
29
|
[k: string]: unknown;
|
|
24
30
|
}
|
|
25
31
|
/** @public */
|
|
@@ -43,7 +43,8 @@ export default function PaymentPlan(props) {
|
|
|
43
43
|
const { plan, onSelect, period = "monthly", ownedCodes } = props || {};
|
|
44
44
|
const owned = (code) => !!code && !!ownedCodes?.includes(code);
|
|
45
45
|
// 이 플랜의 오픈특가 tier를 이미 보유(구매)했는지 — 그러면 기본 tier를 카드로 승격
|
|
46
|
-
const
|
|
46
|
+
const isOnceOnlyTier = (tr) => tr.onceOnly ?? /오픈\s*특가/.test(tr.label ?? '');
|
|
47
|
+
const openSoldOut = (plan.tiers || []).some(tr => isOnceOnlyTier(tr) && owned(tr.code));
|
|
47
48
|
// 할인 tier 가 정가를 취소선으로 이미 보여 주면, 같은 값을 가진 정가 행은 같은 상품을 두 번
|
|
48
49
|
// 늘어놓는 셈이 된다(30일 이용권 ₩10,000 행 + 30일 ₩10,000→₩5,000 카드). 그 행은 숨긴다.
|
|
49
50
|
const coveredByDiscount = (tier) => {
|
|
@@ -61,7 +62,8 @@ export default function PaymentPlan(props) {
|
|
|
61
62
|
const tierPriceOf = (tier) => (tier.prices ? tier.prices[period] : tier.price);
|
|
62
63
|
const isFree = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
|
|
63
64
|
const price = tierPriceOf(tier);
|
|
64
|
-
|
|
65
|
+
// 숫자가 모두 0 인 가격도 무료다 — '₩0'·'$0.00' 처럼 로케일마다 표기가 달라 '무료'·'0원' 글자만 보면 영어 화면에서 놓쳤다
|
|
66
|
+
return price === "무료" || price === freePrice || price === "0원" || (typeof price === 'string' && /\d/.test(price) && !/[1-9]/.test(price));
|
|
65
67
|
});
|
|
66
68
|
// 맞춤 견적(문의) 플랜 — 가격이 '문의'(ko) 또는 로케일 견적 문구
|
|
67
69
|
const isInquiry = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
|
|
@@ -85,7 +87,7 @@ export default function PaymentPlan(props) {
|
|
|
85
87
|
읽는** 것이라 한국어 그대로여야 한다. */
|
|
86
88
|
const rawLabel = tier.label || 기본이용권;
|
|
87
89
|
// 라벨의 '오픈 특가'는 계정당 1회 제한의 표시다 — 숨김·소진 판정에만 쓴다.
|
|
88
|
-
const isOnceOnly =
|
|
90
|
+
const isOnceOnly = isOnceOnlyTier(tier);
|
|
89
91
|
// 배지 문구는 별도 — 같은 프로모션이되 1회 제한은 없는 tier(1년권)도 함께 얹는다.
|
|
90
92
|
const showOpenPromo = isOnceOnly || tier.openPromo === true;
|
|
91
93
|
const planText = rawLabel.replace(/^오픈\s*특가\s*/, '') || 기본이용권;
|
|
@@ -65,7 +65,7 @@ export default function PromoBanner({ title, description, actionLabel, onAction,
|
|
|
65
65
|
*/
|
|
66
66
|
const cls = cx('promo-banner', `promo-banner--${size}`, className);
|
|
67
67
|
const Bg = PROMO_BANNER_BG_COMPONENTS[resolvedBg];
|
|
68
|
-
return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
|
|
68
|
+
return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { htmlType: "button", size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
|
|
69
69
|
? { type: 'dot', icon: 'arrow-right' }
|
|
70
70
|
: { rightIcon: 'arrow-right', label: actionLabel, minWidth: 140 }) }))] }));
|
|
71
71
|
}
|
|
@@ -51,7 +51,7 @@ function SideNavButton(props) {
|
|
|
51
51
|
}
|
|
52
52
|
onClick?.(key, e.currentTarget, fromPointer);
|
|
53
53
|
}, [isDisabled, key, onClick]);
|
|
54
|
-
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label":
|
|
54
|
+
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
|
|
55
55
|
}
|
|
56
56
|
/** @public */
|
|
57
57
|
export default function SideNav(props) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
'use client';
|
|
7
7
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
8
8
|
import { useMemo } from 'react';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
9
10
|
import Check from 'lucide-react/dist/esm/icons/check.mjs';
|
|
10
11
|
import Volume2 from 'lucide-react/dist/esm/icons/volume-2.mjs';
|
|
11
12
|
import User from 'lucide-react/dist/esm/icons/user.mjs';
|
|
@@ -15,6 +16,7 @@ import './SoundCompass.css';
|
|
|
15
16
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
16
17
|
/** @public */
|
|
17
18
|
export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
|
|
19
|
+
const { t: 번역 } = useTranslation();
|
|
18
20
|
const t = Math.max(0, Math.min(1, viewT));
|
|
19
21
|
const CENTER = size / 2;
|
|
20
22
|
// ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
|
|
@@ -119,6 +121,13 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
119
121
|
}
|
|
120
122
|
// ── 스피커 시각 상태를 props에서 파생 ──────────────────────────────────
|
|
121
123
|
const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
|
|
124
|
+
const stateLabel = (state) => ({
|
|
125
|
+
idle: '',
|
|
126
|
+
active: 번역('soundCompass.state.active', '소리 나는 중'),
|
|
127
|
+
played: 번역('soundCompass.state.played', '들음'),
|
|
128
|
+
correct: 번역('soundCompass.state.correct', '정답'),
|
|
129
|
+
wrong: 번역('soundCompass.state.wrong', '오답'),
|
|
130
|
+
})[state];
|
|
122
131
|
const getSpeakerState = (sp) => {
|
|
123
132
|
if (activeSpeakerId === sp.id)
|
|
124
133
|
return 'active';
|
|
@@ -209,6 +218,6 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
209
218
|
...(pulseScale !== undefined
|
|
210
219
|
? { ['--sc-pulse-scale']: String(pulseScale) }
|
|
211
220
|
: null),
|
|
212
|
-
} })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": sp.label, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
|
|
221
|
+
} })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": stateLabel(state) ? `${sp.label}, ${stateLabel(state)}` : sp.label, "aria-pressed": selectedId != null ? sp.id === selectedId : undefined, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
|
|
213
222
|
})] }) }));
|
|
214
223
|
}
|
|
@@ -87,6 +87,11 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
87
87
|
return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
|
|
88
88
|
? 'chevron-right'
|
|
89
89
|
: item.badgeCount
|
|
90
|
-
? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-
|
|
91
|
-
: undefined, label: item.label,
|
|
90
|
+
? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-hidden": true })
|
|
91
|
+
: undefined, label: item.label,
|
|
92
|
+
/* 개수는 이름에 붙인다 — ariaLabel 이 글자뿐이라 배지 수가 이름에서 빠져 "알림" 으로만 읽혔다
|
|
93
|
+
(SideNav·BottomNav 와 같은 nav.badgeLabel "알림, 5개"). */
|
|
94
|
+
ariaLabel: item.label && item.badgeCount && item.badgeCount > 0
|
|
95
|
+
? t('nav.badgeLabel', { label: item.label, count: item.badgeCount, defaultValue: '{{label}}, {{count}}개' })
|
|
96
|
+
: item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
|
|
92
97
|
}
|
|
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
|
|
|
70
70
|
fontSize: `${fontSize}px`,
|
|
71
71
|
lineHeight: `${lineHeight}px`,
|
|
72
72
|
}), [fontSize, lineHeight]);
|
|
73
|
-
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
73
|
+
return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
74
74
|
}
|
|
@@ -24,6 +24,10 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
24
24
|
// 실제 로드된(재생되는) 소음 파일 URL — waveform이 화면=청취 일치하도록 이 src를 그린다.
|
|
25
25
|
const [activeNoiseUrl, setActiveNoiseUrl] = useState(null);
|
|
26
26
|
const noiseTypeLoadedRef = useRef(null);
|
|
27
|
+
/* 바꿔 끼울 소음 파일 — 종류를 바꾸면 여기에 두고, 페이드아웃이 끝나거나 **다음 페이드인이 시작될 때** 끼운다.
|
|
28
|
+
전에는 페이드아웃의 완료 콜백에만 걸어 두어, 150ms 안에 다른 페이드(다음 트랙의 fadeInNoise·문장 종료의
|
|
29
|
+
fadeOutNoise·탭 숨김)가 끼면 콜백이 사라졌다 — 종류 표시·파형은 새 것인데 **소리는 이전 파일**이었다. */
|
|
30
|
+
const pendingNoiseUrlRef = useRef(null);
|
|
27
31
|
// volume = 소음 <audio> 볼륨(noiseElementVolume — 전체 소리 × 소음 비율 ÷ 파일 gain, 상한 1)
|
|
28
32
|
const noiseSettingsRef = useRef(null);
|
|
29
33
|
const noiseFadeRafRef = useRef(null);
|
|
@@ -43,7 +47,19 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
43
47
|
}, []);
|
|
44
48
|
const fadeInNoise = useCallback((targetVolume, durationMs = 250) => {
|
|
45
49
|
const noiseEl = noiseAudioRef.current;
|
|
46
|
-
if (!noiseEl
|
|
50
|
+
if (!noiseEl)
|
|
51
|
+
return;
|
|
52
|
+
const pending = pendingNoiseUrlRef.current;
|
|
53
|
+
if (pending) {
|
|
54
|
+
pendingNoiseUrlRef.current = null;
|
|
55
|
+
try {
|
|
56
|
+
noiseEl.pause();
|
|
57
|
+
}
|
|
58
|
+
catch (e) { }
|
|
59
|
+
noiseEl.src = pending;
|
|
60
|
+
noiseEl.load();
|
|
61
|
+
}
|
|
62
|
+
if (!noiseEl.src)
|
|
47
63
|
return;
|
|
48
64
|
noiseFadeTokenRef.current += 1;
|
|
49
65
|
cancelNoiseFade();
|
|
@@ -235,7 +251,12 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
|
|
|
235
251
|
if (url) {
|
|
236
252
|
noiseTypeLoadedRef.current = ns.type;
|
|
237
253
|
setActiveNoiseUrl(url); // waveform이 실제 재생 파일을 그리도록
|
|
254
|
+
pendingNoiseUrlRef.current = url;
|
|
238
255
|
fadeOutNoise(150, () => {
|
|
256
|
+
// 그 사이 다음 페이드인이 이미 끼워 넣었으면(pending 이 비었으면) 다시 끼우지 않는다
|
|
257
|
+
if (pendingNoiseUrlRef.current !== url)
|
|
258
|
+
return;
|
|
259
|
+
pendingNoiseUrlRef.current = null;
|
|
239
260
|
noiseEl.src = url;
|
|
240
261
|
noiseEl.load();
|
|
241
262
|
const mainAudio = audioRef.current;
|
|
@@ -13,9 +13,14 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
13
13
|
const ttsStartRef = useRef(null);
|
|
14
14
|
const ttsEstimatedDurationRef = useRef(0);
|
|
15
15
|
const ttsRafRef = useRef(null);
|
|
16
|
+
/* 지금 말하는 발화의 번호. 정지(cancel)·다시 시작·언마운트에서 올린다 — Chrome·Safari 는 cancel() 한 발화에도
|
|
17
|
+
end 이벤트를 내서, **정지를 누르면 끝까지 들은 것으로** handleEnd → onDone(true) 가 불렸고(문항 완료로 셈),
|
|
18
|
+
언마운트 뒤에 온 end 가 떠난 화면에 완료를 알렸다. 번호가 다른 end 는 넘긴다. */
|
|
19
|
+
const ttsTokenRef = useRef(0);
|
|
16
20
|
useEffect(() => {
|
|
17
21
|
// cleanup TTS resources on unmount
|
|
18
22
|
return () => {
|
|
23
|
+
ttsTokenRef.current++;
|
|
19
24
|
try {
|
|
20
25
|
if (ttsRafRef.current) {
|
|
21
26
|
cancelAnimationFrame(ttsRafRef.current);
|
|
@@ -52,6 +57,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
52
57
|
return;
|
|
53
58
|
}
|
|
54
59
|
const synth = window.speechSynthesis;
|
|
60
|
+
const myToken = ++ttsTokenRef.current;
|
|
55
61
|
try {
|
|
56
62
|
try {
|
|
57
63
|
synth.cancel();
|
|
@@ -99,6 +105,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
99
105
|
catch (e) { }
|
|
100
106
|
};
|
|
101
107
|
utt.onend = () => {
|
|
108
|
+
if (myToken !== ttsTokenRef.current)
|
|
109
|
+
return;
|
|
102
110
|
try {
|
|
103
111
|
if (ttsRafRef.current) {
|
|
104
112
|
cancelAnimationFrame(ttsRafRef.current);
|
|
@@ -149,6 +157,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
149
157
|
}
|
|
150
158
|
}, [ttsText, playbackRate, mainVolume, fadeInNoise, fillRef, waveProgressRef, setProgress, setPlaybackState, setStateLocal, noiseSettingsRef, onDoneRef, handleEndRef]);
|
|
151
159
|
const stopTTS = useCallback(() => {
|
|
160
|
+
ttsTokenRef.current++;
|
|
152
161
|
try {
|
|
153
162
|
if (window.speechSynthesis) {
|
|
154
163
|
try {
|
|
@@ -105,9 +105,10 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
105
105
|
}
|
|
106
106
|
return [...byLabel.entries()].map(([label, its]) => ({ label, items: its }));
|
|
107
107
|
}, [groups, orderedGroupItems, pageClamped, pageSize]);
|
|
108
|
-
// 정렬/페이지크기 변경·결과 개수
|
|
108
|
+
// 정렬/페이지크기 변경·결과 개수 변동·**검색어 변경** 시 1페이지로. 검색어를 안 보면 결과 수가 같은 다른 검색에서
|
|
109
|
+
// 보던 쪽 번호(3쪽)가 남아 새 결과의 3쪽이 떴다.
|
|
109
110
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
110
|
-
useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount]);
|
|
111
|
+
useEffect(() => { setPage(1); }, [sortKey, pageSizeStr, totalCount, searchQuery]);
|
|
111
112
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
112
113
|
// 결과가 있으면(flat/그룹 모두) 툴바(정렬·페이지크기·페이지네이션) 노출.
|
|
113
114
|
// (그룹 뷰도 flat과 동일하게 페이지네이션 — 카드 4 결정 1안. 그룹은 페이지 단위로 재그룹핑되어 표시.)
|
|
@@ -85,10 +85,14 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
85
85
|
// 글자 모드에서 마지막으로 선택돼 있던 모음 자모 — 발음↔글자 왕복 시 병합 버튼(ㅔㅐ 등)의
|
|
86
86
|
// 대표 자모로 뭉개지지 않고 원래 자모를 정확히 복원하기 위해 별도로 기억해 둔다.
|
|
87
87
|
const lastCharJungRef = useRef(phonemeView === 'char' ? composition.jung : null);
|
|
88
|
+
// 종성도 — 발음은 대표음(ㅅ→ㄷ)으로 모으므로 되돌릴 원래 자모를 기억한다
|
|
89
|
+
const lastCharJongRef = useRef(phonemeView === 'char' ? composition.jong : null);
|
|
88
90
|
useEffect(() => {
|
|
89
|
-
if (phonemeView === 'char')
|
|
91
|
+
if (phonemeView === 'char') {
|
|
90
92
|
lastCharJungRef.current = composition.jung;
|
|
91
|
-
|
|
93
|
+
lastCharJongRef.current = composition.jong;
|
|
94
|
+
}
|
|
95
|
+
}, [phonemeView, composition.jung, composition.jong]);
|
|
92
96
|
// resetOnMount(단어훈련 만들기): '초기화'는 필터를 모두 비운 중립 상태여야 하므로 선택 토글도 OFF로 시작한다.
|
|
93
97
|
// (기본 ON이면 단음절 음소 합치기 등에서 '이미지 있는 단어만'에 걸려 첫 진입 결과가 0이 된다.)
|
|
94
98
|
// 단, 카드에 이미지가 필요한 타입(듣고 순서대로 기억하기 등)은 defaultImageOnly로 ON을 요청할 수 있다.
|
|
@@ -109,7 +113,8 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
109
113
|
return;
|
|
110
114
|
resetKeyRef.current = opts?.resetKey;
|
|
111
115
|
setComposition(seedComposition());
|
|
112
|
-
|
|
116
|
+
// 이미지 토글도 그 타입의 기본값으로 — 무조건 false 로 두어 defaultImageOnly 를 요청한 타입(듣고 순서대로 기억하기)이 꺼졌다
|
|
117
|
+
setExcludeNoImage(!!opts?.defaultImageOnly);
|
|
113
118
|
setSameStructureOnly(false);
|
|
114
119
|
setGradeFilter([]);
|
|
115
120
|
setPosFilter([]);
|
|
@@ -520,7 +525,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
520
525
|
if (next === phonemeView)
|
|
521
526
|
return;
|
|
522
527
|
if (next !== 'direct' && phonemeView !== 'direct') {
|
|
523
|
-
setComposition(c => convertComposition(c, next, lastCharJungRef.current));
|
|
528
|
+
setComposition(c => convertComposition(c, next, lastCharJungRef.current, lastCharJongRef.current));
|
|
524
529
|
}
|
|
525
530
|
setPhonemeView(next);
|
|
526
531
|
}, [phonemeView]);
|
|
@@ -51,7 +51,7 @@ export declare function charToPronValue(part: CompositionPart, jamo: string): st
|
|
|
51
51
|
*/
|
|
52
52
|
export declare function pronToCharValue(part: CompositionPart, value: string, preferredJamo?: string | null): string;
|
|
53
53
|
/** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
|
|
54
|
-
export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null): WordComposition;
|
|
54
|
+
export declare function convertComposition(c: WordComposition, to: 'char' | 'pron', preferredCharJung?: string | null, preferredCharJong?: string | null): WordComposition;
|
|
55
55
|
/** 정답 단어 구성 선택 상태 (정답 단어에만 적용 — 오답은 대립 관계로 결정) */
|
|
56
56
|
export interface WordComposition {
|
|
57
57
|
cho: string | null;
|
|
@@ -83,10 +83,15 @@ export function pronToCharValue(part, value, preferredJamo) {
|
|
|
83
83
|
return preferredJamo;
|
|
84
84
|
return jamos[0] ?? value;
|
|
85
85
|
}
|
|
86
|
+
/* 종성도 같다 — 글자→발음은 대표음(ㅅ·ㅈ·ㅊ·ㅌ·ㅎ → ㄷ, ㄳ → ㄱ)으로 모으므로, 되돌릴 때 전환 직전 글자 모드의 종성이
|
|
87
|
+
그 대표음에 속하면 그것을 복원한다. 전에는 대표음 그대로 돌아와 보기만 바꿔도 훈련 대상이 옷·낫에서 곧·받으로
|
|
88
|
+
바뀌어 저장됐다. */
|
|
89
|
+
if (part === 'jong' && preferredJamo && finalToPhoneme(preferredJamo) === value)
|
|
90
|
+
return preferredJamo;
|
|
86
91
|
return value;
|
|
87
92
|
}
|
|
88
93
|
/** 모드 전환 시 composition의 초성/모음/종성을 대상 모드 값으로 변환. 종성 '없음'(NO_CODA)은 유지. */
|
|
89
|
-
export function convertComposition(c, to, preferredCharJung) {
|
|
94
|
+
export function convertComposition(c, to, preferredCharJung, preferredCharJong) {
|
|
90
95
|
const conv = (part, v) => {
|
|
91
96
|
if (!v)
|
|
92
97
|
return null;
|
|
@@ -94,7 +99,7 @@ export function convertComposition(c, to, preferredCharJung) {
|
|
|
94
99
|
return NO_CODA;
|
|
95
100
|
if (to === 'pron')
|
|
96
101
|
return charToPronValue(part, v);
|
|
97
|
-
return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : null);
|
|
102
|
+
return pronToCharValue(part, v, part === 'jung' ? preferredCharJung : part === 'jong' ? preferredCharJong : null);
|
|
98
103
|
};
|
|
99
104
|
return { ...c, cho: conv('cho', c.cho), jung: conv('jung', c.jung), jong: conv('jong', c.jong) };
|
|
100
105
|
}
|
|
@@ -1390,5 +1390,16 @@
|
|
|
1390
1390
|
},
|
|
1391
1391
|
"loader": {
|
|
1392
1392
|
"loading": "Loading"
|
|
1393
|
+
},
|
|
1394
|
+
"soundCompass": {
|
|
1395
|
+
"state": {
|
|
1396
|
+
"active": "playing",
|
|
1397
|
+
"played": "heard",
|
|
1398
|
+
"correct": "correct",
|
|
1399
|
+
"wrong": "wrong"
|
|
1400
|
+
}
|
|
1401
|
+
},
|
|
1402
|
+
"breadcrumb": {
|
|
1403
|
+
"ariaLabel": "Breadcrumb"
|
|
1393
1404
|
}
|
|
1394
1405
|
}
|
|
@@ -1388,5 +1388,16 @@
|
|
|
1388
1388
|
},
|
|
1389
1389
|
"loader": {
|
|
1390
1390
|
"loading": "불러오는 중…"
|
|
1391
|
+
},
|
|
1392
|
+
"soundCompass": {
|
|
1393
|
+
"state": {
|
|
1394
|
+
"active": "소리 나는 중",
|
|
1395
|
+
"played": "들음",
|
|
1396
|
+
"correct": "정답",
|
|
1397
|
+
"wrong": "오답"
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
"breadcrumb": {
|
|
1401
|
+
"ariaLabel": "이동 경로"
|
|
1391
1402
|
}
|
|
1392
1403
|
}
|