@ihabdevteam/core 0.153.0 → 0.154.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 +21 -0
- package/dist/components.cjs.js +178 -173
- package/dist/components.esm.css +3 -3
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +186 -181
- package/dist/ihabdevteam-core.d.ts +37 -3
- package/dist/packages/core/src/components/Table.js +41 -15
- package/dist/packages/core/src/components/TableField.css +32 -0
- package/dist/packages/core/src/components/WordTable.css +48 -1
- package/dist/packages/core/src/components/WordTable.d.ts +7 -1
- package/dist/packages/core/src/components/WordTable.js +168 -10
- package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +16 -2
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +34 -11
- package/dist/packages/core/src/i18n/locales/en.json +5 -0
- package/dist/packages/core/src/i18n/locales/ko.json +5 -0
- package/dist/packages/core/src/utils/lucideIconSet.js +2 -0
- package/dist/packages/core/src/utils/planFeatures.d.ts +28 -1
- package/dist/packages/core/src/utils/planFeatures.js +38 -8
- package/dist/packages/core/src/utils/wordTrainingPool.js +31 -3
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -1
- 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
|
@@ -1460,7 +1460,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1460
1460
|
*
|
|
1461
1461
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1462
1462
|
*/
|
|
1463
|
-
export declare const CORE_VERSION = "0.
|
|
1463
|
+
export declare const CORE_VERSION = "0.154.0";
|
|
1464
1464
|
|
|
1465
1465
|
/** @public */
|
|
1466
1466
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -2715,6 +2715,14 @@ export declare function injectPromoBannerBgTheme(el: HTMLElement | null, theme:
|
|
|
2715
2715
|
/** @public 검색어가 초성으로만 이루어졌는지 (예: 'ㅎㅈ' → true, '한' → false) */
|
|
2716
2716
|
export declare function isAllChoseong(search: string): boolean;
|
|
2717
2717
|
|
|
2718
|
+
/**
|
|
2719
|
+
* @public 티어가 매니저 줄(말귀매니저)인지. `none` 과 개인 줄(free·personal_standard)은 false.
|
|
2720
|
+
*
|
|
2721
|
+
* 요금제 비교표처럼 "이 칸은 매니저 플랜에만 있다" 를 그리는 곳이 티어 목록을 손으로
|
|
2722
|
+
* 들고 있지 않도록 여기서 내준다 — 티어가 늘면 그 목록만 뒤처진다.
|
|
2723
|
+
*/
|
|
2724
|
+
export declare function isManagerTier(tier: PlanTier): boolean;
|
|
2725
|
+
|
|
2718
2726
|
/** @public 자동 문제생성 불가(수동 구성 전용) 타입인가. */
|
|
2719
2727
|
export declare function isManualOnlyType(typeKey: WordTrainingTypeKey): boolean;
|
|
2720
2728
|
|
|
@@ -4155,6 +4163,15 @@ export declare interface PlanFeatures {
|
|
|
4155
4163
|
maxCustomers: number;
|
|
4156
4164
|
visitWord: boolean;
|
|
4157
4165
|
visitSentence: boolean;
|
|
4166
|
+
/**
|
|
4167
|
+
* 숙제내주기(쿠폰) — **노출 여부만** 말한다.
|
|
4168
|
+
*
|
|
4169
|
+
* 하루 몇 번 보낼 수 있는지는 `PLAN_LIMITS[tier].homeworkSendDaily` 가 정본이고,
|
|
4170
|
+
* 어드민 요금제 비교표의 '숙제 보내기' 칸도 그 숫자로 그린다. 이 플래그는 그 숫자가
|
|
4171
|
+
* 있는 티어에서 켜져 있어야 한다 — 전에는 mgr_free·lite·standard 가 하루 1·10·30회를
|
|
4172
|
+
* 받으면서도 false 라, 같은 화면이 한 칸에서는 횟수를 말하고 옆 칸에서는 ✗ 를 말했다
|
|
4173
|
+
* (사용자 결정 2026-09-16). 숫자를 고칠 때는 이 플래그도 같이 본다.
|
|
4174
|
+
*/
|
|
4158
4175
|
homework: boolean;
|
|
4159
4176
|
customerStats: boolean;
|
|
4160
4177
|
managerCustomerSplit: boolean;
|
|
@@ -6713,7 +6730,18 @@ export declare interface TextFieldProps {
|
|
|
6713
6730
|
*/
|
|
6714
6731
|
export declare type ThemeMode = 'light' | 'dark' | 'auto';
|
|
6715
6732
|
|
|
6716
|
-
/**
|
|
6733
|
+
/**
|
|
6734
|
+
* 요금제 티어 순위 — **같은 줄 안에서 견주는 값**이다(클수록 상위 요금제).
|
|
6735
|
+
*
|
|
6736
|
+
* 줄마다 자리를 갈라 둔다: 개인 줄 0~9, 매니저 줄 10~19. 전에는 한 자리에 섞여 있어
|
|
6737
|
+
* (personal_standard 3 < mgr_free 4) 줄이 다른 두 티어의 순위 차가 무슨 뜻인지 읽을 수
|
|
6738
|
+
* 없었다(사용자 결정 2026-09-16). 줄이 같은지는 `isManagerTier` 로 본다.
|
|
6739
|
+
*
|
|
6740
|
+
* 줄 사이의 크기 비교(매니저 줄이 개인 줄보다 큼)는 전과 같은 방향이라, 활성 구독 중
|
|
6741
|
+
* 가장 높은 티어를 고르는 코드는 그대로 동작한다 — 다만 그런 비교는 "매니저 줄이
|
|
6742
|
+
* 개인 줄보다 상위"라는 뜻이지 기능 수의 많고 적음이 아니다.
|
|
6743
|
+
* 레거시 라이트(낱말·문장)는 서로 대등해 같은 자리를 쓴다.
|
|
6744
|
+
*/
|
|
6717
6745
|
export declare const TIER_RANK: Record<PlanTier, number>;
|
|
6718
6746
|
|
|
6719
6747
|
/**
|
|
@@ -9506,7 +9534,13 @@ export declare interface WordTableProps {
|
|
|
9506
9534
|
onRemoveCell?: (index: number) => void;
|
|
9507
9535
|
/** 표시 항목 제거 */
|
|
9508
9536
|
onRemoveItem?: (id: string) => void;
|
|
9509
|
-
/**
|
|
9537
|
+
/**
|
|
9538
|
+
* 표시 항목 순서 바꾸기. 칸을 옮길 수 있게 하고 `(fromIndex, toIndex)` 를 알린다.
|
|
9539
|
+
*
|
|
9540
|
+
* 옮기는 길이 셋이다 — **마우스로 끌기**(HTML5 드래그 앤 드롭), 칸 안 **옮기기 손잡이**에
|
|
9541
|
+
* 초점을 두고 `Alt`+`←`/`→`, 그리고 **길게 눌러**(400ms) 손가락으로 끌기.
|
|
9542
|
+
* 키보드로 옮기면 초점이 그 칸을 따라가고 바뀐 자리를 `role="status"` 로 알린다.
|
|
9543
|
+
*/
|
|
9510
9544
|
onReorder?: (fromIndex: number, toIndex: number) => void;
|
|
9511
9545
|
/** 표시 모드에서 `count` 안의 빈칸(항목 수 뒤 ~ count)에 '추가' 버튼 노출. count 가 항목 수와 같으면 그런 칸이 없다. `onActivateCell` 필요. */
|
|
9512
9546
|
showFillerAdd?: boolean;
|
|
@@ -47,20 +47,31 @@ function SortArrow({ dir }) {
|
|
|
47
47
|
const Icon = dir === 'asc' ? ChevronUp : ChevronDown;
|
|
48
48
|
return _jsx(Icon, { className: "table-field__sort-arrow", size: 16, strokeWidth: 2 });
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
/**
|
|
51
|
+
* 머리 한 칸.
|
|
52
|
+
*
|
|
53
|
+
* **`aria-sort` 는 머리 칸에 붙는다 — 누르는 것에 붙이면 무시된다.**
|
|
54
|
+
* 예전에는 이 `div` 자체가 `role="button"` 이었고 그 위에 `aria-sort` 를 얹었다.
|
|
55
|
+
* `aria-sort` 는 `columnheader`·`rowheader` 에만 뜻이 있는 속성이라 `button` 에
|
|
56
|
+
* 붙은 것은 통째로 버려진다 — 낭독기는 "이름, 버튼" 까지만 읽고 어느 칸으로 어느
|
|
57
|
+
* 방향으로 정렬됐는지는 한 마디도 안 했다. 눈에는 화살표가 보여 오래 안 드러났다.
|
|
58
|
+
*
|
|
59
|
+
* 그래서 **칸과 누르는 것을 가른다.** 칸은 `columnheader` 로 남아 `aria-sort` 를 들고,
|
|
60
|
+
* 누르는 것은 그 안의 **진짜 `<button>`** 이다. 진짜 단추라 Enter·Space 는 브라우저가
|
|
61
|
+
* 처리한다 — 손으로 붙이던 `onKeyDown` 이 필요 없다.
|
|
62
|
+
*
|
|
63
|
+
* `onClick` 은 단추가 아니라 **칸**에 그대로 둔다. 단추가 낸 클릭도 여기로 올라오므로
|
|
64
|
+
* 동작은 같고, 여백(padding)을 눌러도 정렬되던 것 · 칸을 집어 누르던 부르는 쪽 코드가
|
|
65
|
+
* 그대로 산다. 칸에는 `role`·`tabIndex` 를 주지 않으므로 탭 정지점은 단추 하나뿐이다.
|
|
66
|
+
*/
|
|
67
|
+
function TableField({ children, onClick, className, sortDir, ariaSort, onResizeStart }) {
|
|
68
|
+
const 속 = _jsxs(_Fragment, { children: [children, sortDir && _jsx(SortArrow, { dir: sortDir })] });
|
|
69
|
+
return (_jsxs("div", { onClick: onClick, role: "columnheader", "aria-sort": ariaSort, className: cx('table-field body-3', className, sortDir ? 'table-field--sorted' : '', onResizeStart ? 'table-field--resizable' : ''), children: [onClick
|
|
70
|
+
/* 단추 안에 **또 다른 조작**을 넣지 않는다 — `label` 에 `role="button"` 인 조각이나
|
|
71
|
+
Checkbox 를 넣어 두면 그것이 이 단추 안으로 들어와 겹친 조작이 된다(HTML 위반).
|
|
72
|
+
그런 머리는 `onClick` 없이 두고, 정렬은 그 조각이 알아서 맡는다. */
|
|
73
|
+
? _jsx("button", { type: "button", className: "table-field__button", children: 속 })
|
|
74
|
+
: 속, onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
|
|
64
75
|
}
|
|
65
76
|
/** @public */
|
|
66
77
|
export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
|
|
@@ -298,6 +309,21 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
298
309
|
'머리와 몸통은 서로 다른 격자라 그 트랙이 각자 다르게 풀려 열이 어긋납니다. px 이나 fr 을 쓰세요. ' +
|
|
299
310
|
`(받은 값: ${gridTemplateColumns})`);
|
|
300
311
|
}
|
|
312
|
+
/* **정렬 중인 칸은 하나뿐이다.** `aria-sort` 는 표 하나에 한 칸만 가질 수 있다(WAI-ARIA) —
|
|
313
|
+
둘에 붙으면 낭독기가 어느 쪽이 지금 정렬인지 못 고른다. 여럿이 오면 **첫 칸만** 방향을 갖고
|
|
314
|
+
나머지는 누를 수 있는 칸의 기본값(none)으로 떨어뜨린 뒤 개발 모드에서 알린다.
|
|
315
|
+
(실제 부르는 쪽 — `useTablePaging`·`ListPopup` — 은 이미 하나만 준다.) */
|
|
316
|
+
const 정렬칸 = fields.findIndex(f => !!f.sortDir);
|
|
317
|
+
if (fields.filter(f => !!f.sortDir).length > 1) {
|
|
318
|
+
devWarnOnce('table-multi-sort', '[ihabdevteam-core] Table 의 fields 에 sortDir 이 둘 이상 있습니다. ' +
|
|
319
|
+
'aria-sort 는 표 하나에 한 칸만 가질 수 있어 첫 칸만 방향으로 알리고 나머지는 none 으로 둡니다.');
|
|
320
|
+
}
|
|
321
|
+
/** 머리 칸의 `aria-sort` — 정렬 중인 한 칸만 방향, 누를 수 있는 나머지는 none, 정렬과 무관한 칸에는 안 붙인다 */
|
|
322
|
+
const ariaSortOf = (i) => {
|
|
323
|
+
if (i === 정렬칸)
|
|
324
|
+
return fields[i].sortDir === 'asc' ? 'ascending' : 'descending';
|
|
325
|
+
return fields[i].onClick ? 'none' : undefined;
|
|
326
|
+
};
|
|
301
327
|
const isFixedPx = (i) => /^\d+(\.\d+)?px$/.test(defaultCols[i] ?? '');
|
|
302
328
|
/* `자동맞춤` 은 이 둘보다 위에서 정의되므로(콜백이라 부를 때 읽힌다) ref 로 건넨다 */
|
|
303
329
|
isFixedPxRef.current = isFixedPx;
|
|
@@ -318,7 +344,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
318
344
|
return; if (e.key === 'Enter' || e.key === ' ') {
|
|
319
345
|
e.preventDefault();
|
|
320
346
|
onToggleCollapse();
|
|
321
|
-
} } : undefined, children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
|
|
347
|
+
} } : undefined, children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir, ariaSort: ariaSortOf(i),
|
|
322
348
|
/* fit 모드는 오른쪽 이웃에서 폭을 덜어 오므로, 덜어 올 컬럼이 없는 자리에는
|
|
323
349
|
손잡이를 두지 않는다 — 두면 그 컬럼만 grow처럼 넓어져 표가 밀린다. */
|
|
324
350
|
onResizeStart: 폭조절 && i < fields.length - 1 && f.resizable !== false
|
|
@@ -17,6 +17,38 @@
|
|
|
17
17
|
overflow: hidden;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/* ── 정렬 머리의 단추 ──────────────────────────────────────
|
|
21
|
+
머리 칸(.table-field)은 columnheader 로 남아 aria-sort 를 들고, **누르는 것은
|
|
22
|
+
그 안의 진짜 <button>** 이다(Table.tsx 의 TableField 주석 참고).
|
|
23
|
+
|
|
24
|
+
**여백(padding)은 칸에 그대로 둔다.** 단추로 옮기면 소비 앱이 `.table-field`
|
|
25
|
+
padding 을 덮어쓴 자리(말귀 어드민 DataPage 는 `0 12px`)에서 둘이 더해져
|
|
26
|
+
28px 이 된다. 칸 여백은 그대로고 단추는 내용 상자를 꽉 채운다 — 여백을 눌러도
|
|
27
|
+
칸의 onClick 으로 그대로 정렬된다(초점 테두리만 글자 쪽에 붙는다).
|
|
28
|
+
|
|
29
|
+
높이도 물려받게 `align-self: stretch` — kscan 이 `.table-field { height: 38px }`
|
|
30
|
+
로 줄여 둔 인쇄용 표에서 단추가 제 높이로 부풀지 않게 한다. */
|
|
31
|
+
.table-field__button {
|
|
32
|
+
flex: 1 1 auto;
|
|
33
|
+
min-width: 0;
|
|
34
|
+
align-self: stretch;
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 0;
|
|
38
|
+
padding: 0;
|
|
39
|
+
margin: 0;
|
|
40
|
+
border: 0;
|
|
41
|
+
background: none;
|
|
42
|
+
/* 글꼴·색은 칸에서 물려받는다 — body-3 와 .table-field:hover 색이 그대로 산다 */
|
|
43
|
+
font: inherit;
|
|
44
|
+
color: inherit;
|
|
45
|
+
text-align: left;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
white-space: nowrap;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
user-select: none;
|
|
50
|
+
}
|
|
51
|
+
|
|
20
52
|
/* ── 컬럼 폭 리사이즈 핸들 (헤더 경계선 드래그) ── */
|
|
21
53
|
.table-field--resizable { position: relative; }
|
|
22
54
|
/* 손잡이는 **열 안쪽**에 둔다. 예전엔 right:-5px 로 경계에 걸치게 뒀는데, 그러면
|
|
@@ -109,6 +109,37 @@
|
|
|
109
109
|
z-index: 2;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/* 손가락에 끌려가는 중인 칸(길게 누르기로 시작한 끌기).
|
|
113
|
+
touch-action: none 은 **끌기가 익은 뒤에만** 걸린다 — 늘 걸어 두면 표 위에서 화면이
|
|
114
|
+
스크롤되지 않는다. 그래서 이 클래스에만 둔다(막는 몫의 대부분은 WordTable.tsx 가
|
|
115
|
+
passive:false 로 단 touchmove 가 하고, 이건 뒤늦게 오는 손짓을 막는 보탬이다). */
|
|
116
|
+
.word-table-block__card--dragging {
|
|
117
|
+
touch-action: none;
|
|
118
|
+
opacity: 0.6;
|
|
119
|
+
transform: scale(0.97);
|
|
120
|
+
box-shadow: var(--shadow-dropdown);
|
|
121
|
+
z-index: 3;
|
|
122
|
+
transition: transform 0.12s, opacity 0.12s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* 옮기기 손잡이 — 빼기 단추와 같은 규칙(평소엔 숨고 hover·초점에 드러난다), 자리만 왼쪽이다 */
|
|
126
|
+
.word-table-block__card-handle {
|
|
127
|
+
position: absolute;
|
|
128
|
+
top: 20px;
|
|
129
|
+
left: 20px;
|
|
130
|
+
z-index: 1;
|
|
131
|
+
opacity: 0;
|
|
132
|
+
transition: opacity 0.15s;
|
|
133
|
+
cursor: grab;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.word-table-block__card:focus-within .word-table-block__card-handle,
|
|
137
|
+
.word-table-block__card-handle:focus-within,
|
|
138
|
+
.word-table-block__card-handle:focus-visible,
|
|
139
|
+
.word-table-block__card:hover .word-table-block__card-handle {
|
|
140
|
+
opacity: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
112
143
|
.word-table-block__card-close {
|
|
113
144
|
position: absolute;
|
|
114
145
|
top: 20px;
|
|
@@ -270,12 +301,27 @@
|
|
|
270
301
|
(보이지 않는 단추를 짐작으로 눌러야 했다). 늘 보이게만 한다 — 색·모양은 그대로. 인쇄에서는 아래 @media print 가 치운다. */
|
|
271
302
|
@media (hover: none) {
|
|
272
303
|
.word-table-block__card-close,
|
|
304
|
+
.word-table-block__card-handle,
|
|
273
305
|
.word-table-cell__play-button,
|
|
274
306
|
.word-table-cell__info-button {
|
|
275
307
|
opacity: 1;
|
|
276
308
|
}
|
|
277
309
|
}
|
|
278
310
|
|
|
311
|
+
/* 움직임을 줄여 달라고 한 사람에게는 끌기 중 움직임 효과를 뺀다 — 자리가 바뀌었다는 것은
|
|
312
|
+
테두리(--drag-over)와 흐려짐만으로도 알 수 있다. 크기가 튀는 것이 어지럼을 부른다. */
|
|
313
|
+
@media (prefers-reduced-motion: reduce) {
|
|
314
|
+
.word-table-block__card,
|
|
315
|
+
.word-table-block__card-handle,
|
|
316
|
+
.word-table-block__card-close {
|
|
317
|
+
transition: none;
|
|
318
|
+
}
|
|
319
|
+
.word-table-block__card--dragging {
|
|
320
|
+
transform: none;
|
|
321
|
+
transition: none;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
279
325
|
.word-table-cell__image-wrap {
|
|
280
326
|
position: relative;
|
|
281
327
|
width: 240px;
|
|
@@ -345,7 +391,8 @@
|
|
|
345
391
|
transform: none !important;
|
|
346
392
|
}
|
|
347
393
|
|
|
348
|
-
.word-table-block__card-close
|
|
394
|
+
.word-table-block__card-close,
|
|
395
|
+
.word-table-block__card-handle { display: none; }
|
|
349
396
|
|
|
350
397
|
.word-table-cell__play-button,
|
|
351
398
|
.word-table-cell__info-button,
|
|
@@ -28,7 +28,13 @@ export interface WordTableProps {
|
|
|
28
28
|
onRemoveCell?: (index: number) => void;
|
|
29
29
|
/** 표시 항목 제거 */
|
|
30
30
|
onRemoveItem?: (id: string) => void;
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* 표시 항목 순서 바꾸기. 칸을 옮길 수 있게 하고 `(fromIndex, toIndex)` 를 알린다.
|
|
33
|
+
*
|
|
34
|
+
* 옮기는 길이 셋이다 — **마우스로 끌기**(HTML5 드래그 앤 드롭), 칸 안 **옮기기 손잡이**에
|
|
35
|
+
* 초점을 두고 `Alt`+`←`/`→`, 그리고 **길게 눌러**(400ms) 손가락으로 끌기.
|
|
36
|
+
* 키보드로 옮기면 초점이 그 칸을 따라가고 바뀐 자리를 `role="status"` 로 알린다.
|
|
37
|
+
*/
|
|
32
38
|
onReorder?: (fromIndex: number, toIndex: number) => void;
|
|
33
39
|
/** 표시 모드에서 `count` 안의 빈칸(항목 수 뒤 ~ count)에 '추가' 버튼 노출. count 가 항목 수와 같으면 그런 칸이 없다. `onActivateCell` 필요. */
|
|
34
40
|
showFillerAdd?: boolean;
|
|
@@ -22,6 +22,20 @@ import { claimSound } from './Training/currentSound';
|
|
|
22
22
|
통째로 날아간다. 쓰는 쪽은 `wordTableLayout` 에서 바로 가져온다.
|
|
23
23
|
(상수 하나는 남긴다 — 규칙이 상수는 허용하고, 지우면 공개 API 가 바뀐다.) */
|
|
24
24
|
export { WORD_TABLE_PAPER } from './wordTableLayout';
|
|
25
|
+
/** 눈에는 안 보이고 낭독기에는 읽히는 글자(ProgressIndicator 와 같은 규칙) */
|
|
26
|
+
const 보이지않게 = {
|
|
27
|
+
position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
|
|
28
|
+
clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* **길게 누르면 끌기가 시작된다** — 터치로 순서를 바꾸는 길.
|
|
32
|
+
*
|
|
33
|
+
* 300~500ms 가 흔한 값이다(iOS 의 길게 누르기는 500ms, Android 는 400ms 안팎).
|
|
34
|
+
* 짧으면 살짝 스친 손가락에 칸이 끌려가고, 길면 "안 되네" 하고 손을 뗀다.
|
|
35
|
+
*/
|
|
36
|
+
const 길게누름_MS = 400;
|
|
37
|
+
/** 길게 누르기가 익기 전에 이만큼 움직이면 그건 끌기가 아니라 **스크롤**이라 취소한다 */
|
|
38
|
+
const 움직임문턱_PX = 8;
|
|
25
39
|
function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true, showImage = true, showPronunciation = true, showColloquial = true, buttonSize = 40, wordInfo, }) {
|
|
26
40
|
const { t } = useTranslation();
|
|
27
41
|
const outerRef = useRef(null);
|
|
@@ -182,6 +196,140 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
182
196
|
// 드래그 상태(표시 모드 재정렬)
|
|
183
197
|
const dragIndex = useRef(null);
|
|
184
198
|
const [dragOver, setDragOver] = useState(null);
|
|
199
|
+
/* ── 칸 순서 바꾸기 — 마우스 밖의 두 길 ───────────────────────────────
|
|
200
|
+
`onReorder` 는 HTML5 드래그 앤 드롭뿐이었다. 키보드만 쓰는 사람에게는 순서를
|
|
201
|
+
바꿀 길이 아예 없었고(끌 수 있다는 것조차 알 수 없다), 터치 기기에서는 HTML5 DnD 가
|
|
202
|
+
굴러가지도 않아 손가락으로도 못 바꿨다. 표를 만드는 화면의 핵심 조작인데
|
|
203
|
+
마우스 있는 사람만 쓸 수 있었다. 마우스 끌기는 그대로 두고 두 길을 더한다. */
|
|
204
|
+
const 낱말수 = items?.length ?? 0;
|
|
205
|
+
/** 지금 손가락에 끌려가는 칸 */
|
|
206
|
+
const [끌기, set끌기] = useState(null);
|
|
207
|
+
/** 낭독기에 바뀐 자리를 알리는 글. 처음에는 비워 둔다 — 열자마자 뜬금없이 읽지 않게. */
|
|
208
|
+
const [옮김알림, set옮김알림] = useState('');
|
|
209
|
+
/** 다시 그린 뒤 초점을 둘 칸 번호 — 옮긴 칸을 초점이 따라가야 다음 Alt+화살표가 같은 칸을 민다 */
|
|
210
|
+
const 초점갈곳 = useRef(null);
|
|
211
|
+
/** 칸 격자 DOM — 손가락 밑의 칸을 찾고, 옮긴 뒤 손잡이에 초점을 주는 데 쓴다 */
|
|
212
|
+
const 격자 = useRef(null);
|
|
213
|
+
/** 지금 떨어뜨릴 자리(상태와 같은 값의 ref) — pointerup 은 마지막 렌더를 기다려 주지 않는다 */
|
|
214
|
+
const 떨굴자리 = useRef(null);
|
|
215
|
+
/** 길게 누르기가 걸어 둔 것들을 한 번에 푸는 함수 */
|
|
216
|
+
const 풀기 = useRef(null);
|
|
217
|
+
/** `contentRef`(인쇄·캡처용, 부르는 쪽 것)와 우리 `격자` 를 함께 채운다 */
|
|
218
|
+
const 내용ref = useCallback((el) => {
|
|
219
|
+
격자.current = el;
|
|
220
|
+
if (typeof contentRef === 'function')
|
|
221
|
+
contentRef(el);
|
|
222
|
+
else if (contentRef)
|
|
223
|
+
contentRef.current = el;
|
|
224
|
+
}, [contentRef]);
|
|
225
|
+
const 옮기기 = useCallback((from, to) => {
|
|
226
|
+
초점갈곳.current = to;
|
|
227
|
+
onReorder?.(from, to);
|
|
228
|
+
set옮김알림(t('wordTable.reorder.moved', {
|
|
229
|
+
n: to + 1, total: 낱말수, defaultValue: '{{total}}칸 가운데 {{n}}번째로 옮김',
|
|
230
|
+
}));
|
|
231
|
+
}, [onReorder, t, 낱말수]);
|
|
232
|
+
/* 옮긴 칸을 초점이 따라간다. 자리를 잃으면 다음 Alt+화살표가 엉뚱한 칸을 민다 —
|
|
233
|
+
아예 문서 처음으로 튀어 어디였는지도 알 수 없다. 의존성 배열 없이 매 렌더 뒤에
|
|
234
|
+
보되 `초점갈곳` 이 비어 있으면 곧바로 돌아온다(거의 언제나 그렇다). */
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const to = 초점갈곳.current;
|
|
237
|
+
if (to == null)
|
|
238
|
+
return;
|
|
239
|
+
초점갈곳.current = null;
|
|
240
|
+
const 칸 = 격자.current?.querySelectorAll('.word-table-block__card:not(.word-table-block__card--filler)');
|
|
241
|
+
칸?.[to]?.querySelector('.word-table-block__card-handle button')?.focus();
|
|
242
|
+
});
|
|
243
|
+
/* 떠날 때 걸어 둔 것을 푼다 — 길게 누르는 중에 팝업이 닫히면 window 리스너와
|
|
244
|
+
타이머가 남아 사라진 화면의 상태를 건드린다(표의 폭 조절에서 겪은 것과 같다). */
|
|
245
|
+
useEffect(() => () => { 풀기.current?.(); }, []);
|
|
246
|
+
/**
|
|
247
|
+
* **키보드로 옮긴다** — 손잡이에 초점을 두고 `Alt`+`←`/`→`.
|
|
248
|
+
*
|
|
249
|
+
* 낱말 카드 안 다른 단추(재생·정보·빼기)는 Enter·Space 로 눌리고, 화살표만으로는
|
|
250
|
+
* 아무 일도 안 일어난다. `Alt` 를 얹어 그 둘 어느 쪽과도 부딪히지 않게 했다.
|
|
251
|
+
*/
|
|
252
|
+
const 키로옮기기 = useCallback((e, index) => {
|
|
253
|
+
if (!onReorder)
|
|
254
|
+
return;
|
|
255
|
+
if (!e.altKey || e.ctrlKey || e.metaKey)
|
|
256
|
+
return;
|
|
257
|
+
const 뒤로 = e.key === 'ArrowRight';
|
|
258
|
+
if (!뒤로 && e.key !== 'ArrowLeft')
|
|
259
|
+
return;
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
const to = index + (뒤로 ? 1 : -1);
|
|
262
|
+
if (to < 0 || to >= 낱말수)
|
|
263
|
+
return; // 양 끝 — 아무 일도 하지 않는다
|
|
264
|
+
옮기기(index, to);
|
|
265
|
+
}, [onReorder, 낱말수, 옮기기]);
|
|
266
|
+
/**
|
|
267
|
+
* **손가락으로 옮긴다** — 길게 누르면(400ms) 끌기가 시작되고 손가락 밑의 칸으로 떨어진다.
|
|
268
|
+
*
|
|
269
|
+
* 마우스는 손대지 않는다. 기존 HTML5 드래그 앤 드롭이 그대로 맡는다 —
|
|
270
|
+
* 두 길이 같은 포인터를 두고 다투면 끌기가 중간에 끊긴다.
|
|
271
|
+
*/
|
|
272
|
+
const 길게눌러끌기 = useCallback((e, index) => {
|
|
273
|
+
if (!onReorder || e.pointerType === 'mouse')
|
|
274
|
+
return;
|
|
275
|
+
/* 칸 안 단추를 길게 누른 것은 그 단추 몫이다(재생·정보·빼기) — 옮기기 손잡이만 예외.
|
|
276
|
+
안 가르면 발음을 들으려고 꾹 누른 손가락에 칸이 끌려간다. */
|
|
277
|
+
const 눌린단추 = e.target.closest?.('button');
|
|
278
|
+
if (눌린단추 && !눌린단추.closest('.word-table-block__card-handle'))
|
|
279
|
+
return;
|
|
280
|
+
풀기.current?.();
|
|
281
|
+
const 시작 = { x: e.clientX, y: e.clientY };
|
|
282
|
+
let 익음 = false;
|
|
283
|
+
/* 끌기 중에는 화면이 따라 스크롤되면 안 된다. React 가 다는 touchmove 는 passive 라
|
|
284
|
+
거기서는 막을 수 없어, 익은 순간 직접 단다(passive:false). */
|
|
285
|
+
const 스크롤막기 = (ev) => { if (익음 && ev.cancelable)
|
|
286
|
+
ev.preventDefault(); };
|
|
287
|
+
const 이동 = (ev) => {
|
|
288
|
+
if (!익음) {
|
|
289
|
+
if (Math.abs(ev.clientX - 시작.x) > 움직임문턱_PX || Math.abs(ev.clientY - 시작.y) > 움직임문턱_PX)
|
|
290
|
+
해제();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
/* 손가락 밑의 칸. `elementFromPoint` 가 없는 환경(구형·시험)에서는 자리를 못 고른다 —
|
|
294
|
+
그때는 떨어뜨려도 아무 일이 없다(끌기가 시작되지 않은 것과 같다). */
|
|
295
|
+
const 밑 = document.elementFromPoint?.(ev.clientX, ev.clientY);
|
|
296
|
+
const 칸 = 밑?.closest?.('[data-wordtable-cell]');
|
|
297
|
+
const i = 칸 ? Number(칸.dataset.wordtableCell) : NaN;
|
|
298
|
+
const 자리 = Number.isInteger(i) ? i : null;
|
|
299
|
+
떨굴자리.current = 자리;
|
|
300
|
+
setDragOver(자리);
|
|
301
|
+
};
|
|
302
|
+
const 뗌 = () => {
|
|
303
|
+
const 자리 = 떨굴자리.current;
|
|
304
|
+
const 끌었다 = 익음;
|
|
305
|
+
해제();
|
|
306
|
+
if (끌었다 && 자리 != null && 자리 !== index)
|
|
307
|
+
옮기기(index, 자리);
|
|
308
|
+
};
|
|
309
|
+
const 타이머 = setTimeout(() => {
|
|
310
|
+
익음 = true;
|
|
311
|
+
set끌기(index);
|
|
312
|
+
떨굴자리.current = index;
|
|
313
|
+
setDragOver(index);
|
|
314
|
+
window.addEventListener('touchmove', 스크롤막기, { passive: false });
|
|
315
|
+
}, 길게누름_MS);
|
|
316
|
+
function 해제() {
|
|
317
|
+
clearTimeout(타이머);
|
|
318
|
+
window.removeEventListener('pointermove', 이동);
|
|
319
|
+
window.removeEventListener('pointerup', 뗌);
|
|
320
|
+
window.removeEventListener('pointercancel', 뗌);
|
|
321
|
+
window.removeEventListener('touchmove', 스크롤막기);
|
|
322
|
+
익음 = false;
|
|
323
|
+
떨굴자리.current = null;
|
|
324
|
+
풀기.current = null;
|
|
325
|
+
set끌기(null);
|
|
326
|
+
setDragOver(null);
|
|
327
|
+
}
|
|
328
|
+
풀기.current = 해제;
|
|
329
|
+
window.addEventListener('pointermove', 이동);
|
|
330
|
+
window.addEventListener('pointerup', 뗌);
|
|
331
|
+
window.addEventListener('pointercancel', 뗌);
|
|
332
|
+
}, [onReorder, 옮기기]);
|
|
185
333
|
const { width, height } = WORD_TABLE_PAPER[orientation];
|
|
186
334
|
// 스케일러 — 용지(width 고정)를 컨테이너 폭에 맞춰 축소 + 가운데 정렬. fitHeight면 높이도 함께 맞춘다.
|
|
187
335
|
const { scalerRef, scale, offset } = useFitScale(width, fitHeight ? height : undefined);
|
|
@@ -200,16 +348,26 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
200
348
|
const contentClass = cx('word-table-block__content', CUT_CLASS[cutLine], showOutline && 'word-table-block__content--outline', showDivLine && hasPairs && 'word-table-block__content--div-line');
|
|
201
349
|
// 빈칸(추가버튼)은 스케일 역보정으로 실제 크기 유지
|
|
202
350
|
const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
|
|
203
|
-
const grid = (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
351
|
+
const grid = (_jsxs("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: [_jsx("div", {
|
|
352
|
+
/* title 로 Section 에 감쌀 때는 className 을 바깥 한 곳에만 — 두 곳에 붙어 소비자 스타일이 두 번 걸렸다 */
|
|
353
|
+
className: cx('word-table', !wrapped && className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: 내용ref, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
|
|
354
|
+
// 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
|
|
355
|
+
colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { "data-wordtable-cell": onReorder ? index : undefined, className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}${끌기 === index ? ' word-table-block__card--dragging' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onPointerDown: onReorder ? (e) => 길게눌러끌기(e, index) : undefined, onKeyDown: onReorder ? (e) => 키로옮기기(e, index) : undefined, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
|
|
356
|
+
onReorder(dragIndex.current, index);
|
|
357
|
+
} ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [(item.words ?? []).map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, (item.words ?? []).map(w => w.label).join(' / ')), onReorder && (
|
|
358
|
+
/* **옮기기 손잡이.** 키보드로 순서를 바꾸려면 초점이 갈 자리가 있어야 하는데,
|
|
359
|
+
칸 자체를 초점 대상으로 만들면 칸마다 뜻 없는 탭 정지점이 하나씩 늘고
|
|
360
|
+
낭독기는 그것이 무엇인지 말할 수 없다. 이름 붙은 진짜 단추를 둔다 —
|
|
361
|
+
"끌 수 있다"는 것을 눈으로도 알려 주는 몫까지 겸한다. */
|
|
362
|
+
_jsx("div", { className: "word-table-block__card-handle", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "grip-vertical", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('wordTable.reorder.hint', 'Alt + ← → 로 순서 바꾸기'),
|
|
363
|
+
/* 표에 손잡이가 열둘이면 "버튼" 만으로는 어느 칸인지 알 길이 없다 — 낱말을 이름에 넣는다 */
|
|
364
|
+
ariaLabel: t('wordTable.reorder.handle', {
|
|
365
|
+
word: (item.words ?? []).map(w => w.label).join(' / '),
|
|
366
|
+
defaultValue: '{{word}} 순서 바꾸기 (Alt + 화살표)',
|
|
367
|
+
}) }) })), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: (item.words ?? []).length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
|
|
368
|
+
const isWithinCount = (items?.length ?? 0) + i < cellCount;
|
|
369
|
+
return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
|
|
370
|
+
})] })), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: `word-table__page-number body-3 word-table__page-number--${pageNumberPosition}`, children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] }) }), onReorder && _jsx("span", { role: "status", style: 보이지않게, children: 옮김알림 })] }));
|
|
213
371
|
if (wrapped) {
|
|
214
372
|
return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: grid }));
|
|
215
373
|
}
|
|
@@ -2,8 +2,22 @@
|
|
|
2
2
|
* 훈련 팝업을 **보고 있던** 시간을 기록합니다.
|
|
3
3
|
* 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 화면이 가려진 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간
|
|
6
|
+
* 잠가 둔 시간이 훈련 시간으로 적혔다.
|
|
7
|
+
*
|
|
8
|
+
* **언제 보내는가**(사용자 결정 2026-09-16):
|
|
9
|
+
* - 떠날 때(팝업을 닫아 훅이 걷힐 때) 또는 페이지가 내려갈 때(pagehide).
|
|
10
|
+
* - **숨길 때도**(visibilitychange → hidden) 아직 안 보냈으면 그때까지 모은 시간을 보낸다.
|
|
11
|
+
* 모바일에서 팝업을 연 채 앱을 스와이프로 닫으면 pagehide 가 안 오는 기기가 있어
|
|
12
|
+
* (visibilitychange 는 온다) 그 판의 시간이 통째로 사라졌다.
|
|
13
|
+
* - 보낸 뒤 돌아와 시간이 더 쌓이면 떠날 때 **더 쌓인 만큼만** 한 줄 더 보낸다(0 이면 안 보냄).
|
|
14
|
+
* 두 줄은 시간이 겹치지 않아 서버가 합산하면 총합이 맞는다.
|
|
15
|
+
*
|
|
16
|
+
* 그래서 **숨기지 않고 그냥 닫는 평범한 흐름은 여전히 한 줄** 이다 — 보이는 구간마다 따로
|
|
17
|
+
* 보내면 서버가 줄 수를 세션 수로 세어(v_user_training_time_stats) 탭을 오갈 때마다 훈련
|
|
18
|
+
* 횟수가 늘었다(0.147 에서 고친 것). 줄이 둘 이상 나오는 건 실제로 앱이 내려갈 뻔한 판뿐이다.
|
|
19
|
+
*
|
|
20
|
+
* 말귀(소비자) 할 일: 서버가 같은 판의 여러 줄을 **합산** 하는지 확인해야 한다.
|
|
7
21
|
*
|
|
8
22
|
* @public
|
|
9
23
|
*/
|
|
@@ -5,8 +5,22 @@ import { logTrainingTime } from '../utils/trainingLogger';
|
|
|
5
5
|
* 훈련 팝업을 **보고 있던** 시간을 기록합니다.
|
|
6
6
|
* 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 화면이 가려진 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간
|
|
9
|
+
* 잠가 둔 시간이 훈련 시간으로 적혔다.
|
|
10
|
+
*
|
|
11
|
+
* **언제 보내는가**(사용자 결정 2026-09-16):
|
|
12
|
+
* - 떠날 때(팝업을 닫아 훅이 걷힐 때) 또는 페이지가 내려갈 때(pagehide).
|
|
13
|
+
* - **숨길 때도**(visibilitychange → hidden) 아직 안 보냈으면 그때까지 모은 시간을 보낸다.
|
|
14
|
+
* 모바일에서 팝업을 연 채 앱을 스와이프로 닫으면 pagehide 가 안 오는 기기가 있어
|
|
15
|
+
* (visibilitychange 는 온다) 그 판의 시간이 통째로 사라졌다.
|
|
16
|
+
* - 보낸 뒤 돌아와 시간이 더 쌓이면 떠날 때 **더 쌓인 만큼만** 한 줄 더 보낸다(0 이면 안 보냄).
|
|
17
|
+
* 두 줄은 시간이 겹치지 않아 서버가 합산하면 총합이 맞는다.
|
|
18
|
+
*
|
|
19
|
+
* 그래서 **숨기지 않고 그냥 닫는 평범한 흐름은 여전히 한 줄** 이다 — 보이는 구간마다 따로
|
|
20
|
+
* 보내면 서버가 줄 수를 세션 수로 세어(v_user_training_time_stats) 탭을 오갈 때마다 훈련
|
|
21
|
+
* 횟수가 늘었다(0.147 에서 고친 것). 줄이 둘 이상 나오는 건 실제로 앱이 내려갈 뻔한 판뿐이다.
|
|
22
|
+
*
|
|
23
|
+
* 말귀(소비자) 할 일: 서버가 같은 판의 여러 줄을 **합산** 하는지 확인해야 한다.
|
|
10
24
|
*
|
|
11
25
|
* @public
|
|
12
26
|
*/
|
|
@@ -14,13 +28,11 @@ export default function useTrainingSessionLog(trainingType) {
|
|
|
14
28
|
useEffect(() => {
|
|
15
29
|
if (!trainingType)
|
|
16
30
|
return; // 타입 미상이면 기록하지 않음
|
|
17
|
-
/* **한 팝업 = 한 줄.** 보이는 구간마다 따로 보내면 서버가 줄 수를 세션 수로 세어(v_user_training_time_stats) 탭을 오갈 때마다
|
|
18
|
-
문장 훈련 횟수가 늘었다. 보이던 시간만 모아 두었다가 떠날 때(또는 페이지가 내려갈 때) 한 번 보낸다 — 끝 시각은 처음 보인 때 + 보인 시간. */
|
|
19
31
|
const 보임 = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
20
32
|
let 처음보임 = null;
|
|
21
33
|
let 구간시작 = null;
|
|
22
34
|
let 모은ms = 0;
|
|
23
|
-
let
|
|
35
|
+
let 보낸ms = 0; // 지금까지 줄로 내보낸 누계 — 다음 줄은 여기서부터 이어 붙인다
|
|
24
36
|
const 구간열기 = () => { const t = Date.now(); if (처음보임 == null)
|
|
25
37
|
처음보임 = t; if (구간시작 == null)
|
|
26
38
|
구간시작 = t; };
|
|
@@ -30,23 +42,34 @@ export default function useTrainingSessionLog(trainingType) {
|
|
|
30
42
|
} };
|
|
31
43
|
if (보임())
|
|
32
44
|
구간열기();
|
|
45
|
+
/* 아직 안 보낸 만큼을 한 줄로. 끝 시각은 처음 보인 때 + 모은 시간, 시작 시각은 앞 줄이
|
|
46
|
+
끝난 자리 — 줄끼리 겹치지 않으니 합산해도 이중으로 세지 않는다.
|
|
47
|
+
전달은 등록된 로거(logTrainingTime)로 간다. 코어는 보낼 주소를 모르므로
|
|
48
|
+
navigator.sendBeacon 을 여기서 직접 쓸 수 없다 — 앱이 등록하는 로거의 몫이다. */
|
|
33
49
|
const 보내기 = () => {
|
|
34
|
-
if (보냄)
|
|
35
|
-
return;
|
|
36
50
|
구간닫기();
|
|
37
|
-
if (처음보임 == null
|
|
51
|
+
if (처음보임 == null)
|
|
52
|
+
return;
|
|
53
|
+
const 남은ms = 모은ms - 보낸ms;
|
|
54
|
+
if (남은ms <= 0)
|
|
55
|
+
return;
|
|
56
|
+
// 첫 줄에만 5초 문턱을 건다(잘못 열었다 닫은 판). 한 번 보낸 판은 이미 실재하는
|
|
57
|
+
// 훈련이라, 돌아와 더 한 시간은 짧아도 버리지 않는다.
|
|
58
|
+
if (보낸ms === 0 && 모은ms < 5000)
|
|
38
59
|
return;
|
|
39
|
-
|
|
60
|
+
const 시작ms = 처음보임 + 보낸ms;
|
|
61
|
+
보낸ms = 모은ms;
|
|
40
62
|
logTrainingTime({
|
|
41
63
|
trainingType,
|
|
42
|
-
startedAt: new Date(
|
|
64
|
+
startedAt: new Date(시작ms).toISOString(),
|
|
43
65
|
endedAt: new Date(처음보임 + 모은ms).toISOString(),
|
|
44
66
|
});
|
|
45
67
|
};
|
|
68
|
+
// 숨으면 보낸다(앱이 그대로 죽을 수 있다). 다시 보이면 이어서 모은다.
|
|
46
69
|
const 보임바뀜 = () => { if (보임())
|
|
47
70
|
구간열기();
|
|
48
71
|
else
|
|
49
|
-
|
|
72
|
+
보내기(); };
|
|
50
73
|
if (typeof document !== 'undefined')
|
|
51
74
|
document.addEventListener('visibilitychange', 보임바뀜);
|
|
52
75
|
// 페이지가 내려가면(탭·앱 닫기) React 정리가 안 돈다 — 그때 보낸다
|
|
@@ -838,6 +838,11 @@
|
|
|
838
838
|
"pronoun": "Pronoun",
|
|
839
839
|
"auxVerb": "Auxiliary verb",
|
|
840
840
|
"auxAdjective": "Auxiliary adjective"
|
|
841
|
+
},
|
|
842
|
+
"reorder": {
|
|
843
|
+
"moved": "Moved to position {{n}} of {{total}}",
|
|
844
|
+
"handle": "Reorder {{word}} (Alt + arrow keys)",
|
|
845
|
+
"hint": "Alt + ← → to reorder"
|
|
841
846
|
}
|
|
842
847
|
},
|
|
843
848
|
"wordSearch": {
|