@ihabdevteam/core 0.311.0 → 0.313.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 +96 -0
- package/dist/components.cjs.js +18 -13
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +61 -56
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +4 -0
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -1
- package/dist/packages/core/src/components/SideNav.js +24 -5
- package/dist/packages/core/src/components/Table.js +8 -0
- package/dist/packages/core/src/components/TextField.js +19 -3
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +16 -1
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +14 -9
- package/dist/packages/core/src/components/Word/wordTablePrint.js +5 -0
- package/dist/packages/core/src/components/WordTable.js +39 -7
- package/dist/packages/core/src/context/I18nProvider.js +11 -2
- package/dist/packages/core/src/context/WordDataProvider.d.ts +1 -1
- package/dist/packages/core/src/context/WordDataProvider.js +13 -1
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +19 -2
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1669,7 +1669,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1669
1669
|
*
|
|
1670
1670
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1671
1671
|
*/
|
|
1672
|
-
export declare const CORE_VERSION = "0.
|
|
1672
|
+
export declare const CORE_VERSION = "0.313.0";
|
|
1673
1673
|
|
|
1674
1674
|
/** @public */
|
|
1675
1675
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -129,6 +129,10 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, b
|
|
|
129
129
|
useEffect(() => {
|
|
130
130
|
if (!떠있음)
|
|
131
131
|
return;
|
|
132
|
+
/* **안-모음: 닫기만 한다**(코어 자체 조사 2026-10-10에서 세어 보고 적음).
|
|
133
|
+
굴림에 하는 일이 `setTooltip(null)` 하나뿐이라 레이아웃을 안 읽는다. 그리고 **미루면 안 된다** —
|
|
134
|
+
풍선은 막대에 붙어 떠 있어서, 한 틀 미루면 막대에서 떨어진 풍선이 그만큼 더 허공에 남는다
|
|
135
|
+
(Tooltip·useAnchoredMenu 의 `close` 길과 같은 가름: *자리 재기는 미뤄도 되고 닫기는 안 된다*). */
|
|
132
136
|
const 닫기 = () => setTooltip(null);
|
|
133
137
|
const 바깥누름 = (e) => {
|
|
134
138
|
const t = e.target;
|
|
@@ -6,6 +6,7 @@ import Popup from './Popup';
|
|
|
6
6
|
import Button from '../Button';
|
|
7
7
|
import Dropdown from '../Dropdown';
|
|
8
8
|
import { getSavedTables, deleteTable as localDeleteTable } from '../../utils/savedWordTables';
|
|
9
|
+
import { 한글순 } from '../../utils/textSort';
|
|
9
10
|
import { formatYmd } from '../../utils/date';
|
|
10
11
|
import './LoadTablePopup.css';
|
|
11
12
|
import { pressable } from '../../utils/pressable';
|
|
@@ -58,8 +59,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
|
|
|
58
59
|
return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
|
|
59
60
|
if (sortKey === 'oldest')
|
|
60
61
|
return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
|
|
62
|
+
/* 이름은 **`한글순`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
|
|
61
63
|
if (sortKey === 'name')
|
|
62
|
-
return copy.sort((a, b) => a.name
|
|
64
|
+
return copy.sort((a, b) => 한글순(a.name, b.name));
|
|
63
65
|
if (sortKey === 'count')
|
|
64
66
|
return copy.sort((a, b) => b.items.length - a.items.length);
|
|
65
67
|
return copy;
|
|
@@ -71,26 +71,45 @@ export default function SideNav(props) {
|
|
|
71
71
|
const el = 통ref.current;
|
|
72
72
|
if (!el)
|
|
73
73
|
return;
|
|
74
|
-
|
|
74
|
+
/* **굴림 한 번에 한 번만 센다**(말귀 요청 (115), 2026-10-10). 전에는 `scroll` 마다 바로 `setState` 라
|
|
75
|
+
굴리는 동안 틀마다 여러 번 다시 그렸다. 틀에 한 번으로 모으고(rAF), **값이 그대로면 상태를 안
|
|
76
|
+
건드린다** — 같은 수를 새 객체로 넘기면 React 는 바뀐 줄 안다. */
|
|
77
|
+
let 예약 = null;
|
|
78
|
+
const 셈 = () => {
|
|
75
79
|
const { scrollHeight, clientHeight, scrollTop } = el;
|
|
76
80
|
/* 넘치지 않으면 그리지 않는다 — 없는 스크롤을 알릴 까닭이 없다 */
|
|
77
81
|
if (scrollHeight <= clientHeight + 1) {
|
|
78
|
-
set막대(null);
|
|
82
|
+
set막대(prev => (prev === null ? prev : null));
|
|
79
83
|
return;
|
|
80
84
|
}
|
|
81
85
|
const 길이 = Math.max(24, (clientHeight / scrollHeight) * clientHeight);
|
|
82
86
|
const 남은길 = clientHeight - 길이;
|
|
83
87
|
const 남은굴림 = scrollHeight - clientHeight;
|
|
84
|
-
|
|
88
|
+
const 다음 = { top: Math.round((scrollTop / 남은굴림) * 남은길), height: Math.round(길이) };
|
|
89
|
+
set막대(prev => (prev && prev.top === 다음.top && prev.height === 다음.height ? prev : 다음));
|
|
85
90
|
};
|
|
86
|
-
재기()
|
|
91
|
+
const 재기 = () => {
|
|
92
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
93
|
+
셈();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (예약 !== null)
|
|
97
|
+
return;
|
|
98
|
+
예약 = requestAnimationFrame(() => { 예약 = null; 셈(); });
|
|
99
|
+
};
|
|
100
|
+
셈();
|
|
87
101
|
el.addEventListener('scroll', 재기, { passive: true });
|
|
88
102
|
/* 항목이 늘거나 창이 바뀌면 길이도 달라진다 */
|
|
89
103
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(재기) : null;
|
|
90
104
|
ro?.observe(el);
|
|
91
105
|
for (const c of Array.from(el.children))
|
|
92
106
|
ro?.observe(c);
|
|
93
|
-
return () => {
|
|
107
|
+
return () => {
|
|
108
|
+
if (예약 !== null)
|
|
109
|
+
cancelAnimationFrame(예약);
|
|
110
|
+
el.removeEventListener('scroll', 재기);
|
|
111
|
+
ro?.disconnect();
|
|
112
|
+
};
|
|
94
113
|
}, [items]);
|
|
95
114
|
/* **막대를 잡아 끌 수 있다.** 손에 잡히게 그려 놓고 안 잡히면 그 꼴이 거짓말이 된다.
|
|
96
115
|
통의 한 픽셀은 막대의 `scrollHeight / clientHeight` 배다 — 그 비로 옮긴다. */
|
|
@@ -227,6 +227,11 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
227
227
|
const 다음틀 = typeof requestAnimationFrame === 'function'
|
|
228
228
|
? requestAnimationFrame(() => 넘침재기())
|
|
229
229
|
: null;
|
|
230
|
+
/* **안-모음: 모을 값이 없다**(2026-10-10 조사에서 한 번 모아 봤다가 되돌림). `넘침재기` 는
|
|
231
|
+
`scrollWidth > clientWidth` 하나를 **불리언**으로 넣는데, 같은 값이면 React 가 그 자리에서
|
|
232
|
+
멈춘다 — 몰려 와도 다시 그리지 않는다. 그리고 바로 위 `requestAnimationFrame(() => 넘침재기())`
|
|
233
|
+
가 *자리잡은 뒤 한 번 더* 재는 일을 이미 하고, `test/tableScrollRegion.test.tsx` 가 **이 배선을
|
|
234
|
+
글자로 박아** 두었다(옛 결함을 지키는 못이다). 모으면 그 못이 지키던 모양이 바뀐다. */
|
|
230
235
|
window.addEventListener('resize', 넘침재기);
|
|
231
236
|
return () => {
|
|
232
237
|
if (다음틀 != null)
|
|
@@ -324,6 +329,9 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
324
329
|
: { index, startX: e.clientX, startW: measured[index] };
|
|
325
330
|
colWidthsRef.current = measured;
|
|
326
331
|
setColWidths(measured);
|
|
332
|
+
/* **안-모음: 곧바로 써야 한다** — `onResizeMove` 가 `colWidthsRef` 를 그 자리에서 맞춘다.
|
|
333
|
+
한 틀 미루면 마지막 이동 직후 손을 떼었을 때 **화면은 새 폭인데 저장소에는 한 박자 전 폭**이
|
|
334
|
+
남는다(그 함수 머리말에 적힌 까닭). 자리 재기와 달리 이것은 *값을 쓰는* 일이다. */
|
|
327
335
|
window.addEventListener('pointermove', onResizeMove);
|
|
328
336
|
window.addEventListener('pointerup', onResizeEnd);
|
|
329
337
|
// 터치 조절이 시스템 제스처 등으로 취소되면(pointercancel) 끝낸다 — 안 들으면 리스너가 남아 다음 스크롤 손짓이 폭을 바꿨다
|
|
@@ -72,17 +72,33 @@ export default function TextField(props) {
|
|
|
72
72
|
return;
|
|
73
73
|
목록자리재기();
|
|
74
74
|
/* 굴리거나 창 크기가 바뀌면 칸을 따라간다 — 목록 안을 굴리는 것은 무시 */
|
|
75
|
+
/* **틀에 한 번으로 모은다** — (115) 와 같은 꼴, 셋째 자리(2026-10-10, 코어 자체 조사). `목록자리재기` 는
|
|
76
|
+
`getBoundingClientRect()` 로 레이아웃을 읽고 상태를 바꾸는데, 이 듣는 이는 **문서 전체의 굴림을
|
|
77
|
+
capture 로** 받는다 — 목록이 뜬 동안 굴리면 틀마다 여러 번 돌았다. 여기는 **닫는 길이 없다**(자리만
|
|
78
|
+
따라간다) — 미뤄도 되는 일뿐이다. */
|
|
79
|
+
let 예약 = null;
|
|
80
|
+
const 자리다시 = () => {
|
|
81
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
82
|
+
목록자리재기();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (예약 !== null)
|
|
86
|
+
return;
|
|
87
|
+
예약 = requestAnimationFrame(() => { 예약 = null; 목록자리재기(); });
|
|
88
|
+
};
|
|
75
89
|
const 굴림 = (e) => {
|
|
76
90
|
const t = e.target;
|
|
77
91
|
if (t && t.nodeType === 1 && t.closest?.('.text-field__suggestions'))
|
|
78
92
|
return;
|
|
79
|
-
|
|
93
|
+
자리다시();
|
|
80
94
|
};
|
|
81
95
|
window.addEventListener('scroll', 굴림, true);
|
|
82
|
-
window.addEventListener('resize',
|
|
96
|
+
window.addEventListener('resize', 자리다시);
|
|
83
97
|
return () => {
|
|
98
|
+
if (예약 !== null)
|
|
99
|
+
cancelAnimationFrame(예약);
|
|
84
100
|
window.removeEventListener('scroll', 굴림, true);
|
|
85
|
-
window.removeEventListener('resize',
|
|
101
|
+
window.removeEventListener('resize', 자리다시);
|
|
86
102
|
};
|
|
87
103
|
}, [목록떴나, 목록자리재기]);
|
|
88
104
|
// 외부 inputRef + 내부 autoRef 동시 연결 — 외부에서 전달받은 ref 객체(.current)에 노드를 채우는
|
|
@@ -140,7 +140,20 @@ export default function Tooltip(props) {
|
|
|
140
140
|
return;
|
|
141
141
|
if (isMobile && isMobilePopup)
|
|
142
142
|
return;
|
|
143
|
-
|
|
143
|
+
/* **자리 다시 재기는 틀에 한 번으로 모은다**(말귀 요청 (115), 2026-10-10). 이 듣는 이는 문서 전체의
|
|
144
|
+
굴림을 **capture** 로 받아, 굴리는 동안 틀마다 여러 번 `computePosition` 이 돌았다(거기서 레이아웃을
|
|
145
|
+
읽는다). **닫는 길은 안 미룬다** — 아래 `onScroll` 의 닫기는 그대로 즉시다. 미루면 앵커에서 떨어진
|
|
146
|
+
툴팁이 한 틀 더 허공에 남는다. 미루는 것은 *자리* 뿐이다. */
|
|
147
|
+
let 예약 = null;
|
|
148
|
+
const onWindowChange = () => {
|
|
149
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
150
|
+
computePosition(true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (예약 !== null)
|
|
154
|
+
return;
|
|
155
|
+
예약 = requestAnimationFrame(() => { 예약 = null; computePosition(true); });
|
|
156
|
+
};
|
|
144
157
|
/* 고정 사각형(anchorRect)으로 띄운 툴팁은 스크롤해도 앵커를 따라갈 수 없다 — 같은 자리를 다시 계산해 **허공에** 남았다
|
|
145
158
|
(목록을 굴리면 카드 메뉴가 카드에서 떨어져 뜬 채). 닫을 곳(onClose)이 있으면 닫는다. 툴팁 안 스크롤은 제외. */
|
|
146
159
|
const onScroll = (e) => {
|
|
@@ -162,6 +175,8 @@ export default function Tooltip(props) {
|
|
|
162
175
|
window.addEventListener('resize', onWindowChange);
|
|
163
176
|
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
164
177
|
return () => {
|
|
178
|
+
if (예약 !== null)
|
|
179
|
+
cancelAnimationFrame(예약);
|
|
165
180
|
window.removeEventListener('resize', onWindowChange);
|
|
166
181
|
document.removeEventListener('scroll', onScroll, { capture: true });
|
|
167
182
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { cx } from '../../utils/cx';
|
|
4
|
+
import { 한글순 } from '../../utils/textSort';
|
|
4
5
|
import { useEscToClose } from '../../hooks/useEscToClose';
|
|
5
6
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
6
7
|
import { useTranslation } from 'react-i18next';
|
|
@@ -13,7 +14,6 @@ import Pagination from '../Pagination';
|
|
|
13
14
|
import WordImageTooltip from '../Tooltip/WordImageTooltip';
|
|
14
15
|
import { useHoverAnchor } from '../../hooks/useHoverAnchor';
|
|
15
16
|
import { makeWordQueryMatcher } from '../../utils/koreanSyllable';
|
|
16
|
-
const 가나다 = new Intl.Collator('ko');
|
|
17
17
|
import { withIga, withEulReul } from '../../utils/josa';
|
|
18
18
|
/** @public */
|
|
19
19
|
export default function WordSearchResultBlock({ titleIcon, resultCount, resultUnit, searchQuery, items, groups, selected = [], onSelect, maxSelect, onClearSelection, onPrint, onPrintEmpty, className, emptyHint, sectionType = 'page', disc, }) {
|
|
@@ -85,7 +85,12 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
85
85
|
return r;
|
|
86
86
|
};
|
|
87
87
|
}, [q]);
|
|
88
|
-
|
|
88
|
+
/* 정렬 비교자 — flat/그룹 뷰가 공유한다.
|
|
89
|
+
**글자 견주기는 `한글순`(콜레이터 하나) 으로만 한다**(말귀 요청 (113), 2026-10-10). 전에는 '시작 우선'
|
|
90
|
+
갈래만 콜레이터를 쓰고 **여섯 갈래는 `localeCompare(…, 'ko')`** 였다 — 그 부름은 비교마다 콜레이터를
|
|
91
|
+
새로 만든다. 하필 기본 정렬이 `ganada` 라 **주된 길이 느린 쪽**이었다(말귀 실측 6,357개: 30.3ms → 8.1ms).
|
|
92
|
+
이 파일에 콜레이터가 하나 더 있었던 것(`new Intl.Collator('ko')`)도 걷었다 — 같은 결정이 두 자리에
|
|
93
|
+
적혀 있으면 한쪽만 고쳐진다. 못: `test/koreanCollatorOnce.test.ts`. */
|
|
89
94
|
const sortCompare = useCallback((a, b) => {
|
|
90
95
|
const label = (it) => firstWord(it)?.label ?? '';
|
|
91
96
|
const syll = (it) => firstWord(it)?.syllables ?? [...label(it)].length;
|
|
@@ -99,14 +104,14 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
|
|
|
99
104
|
// 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
|
|
100
105
|
// 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
|
|
101
106
|
case 'match': {
|
|
102
|
-
return 시작순위(a) - 시작순위(b) ||
|
|
107
|
+
return 시작순위(a) - 시작순위(b) || 한글순(label(a), label(b));
|
|
103
108
|
}
|
|
104
|
-
case 'ganada': return label(a)
|
|
105
|
-
case 'ganada_desc': return label(b)
|
|
106
|
-
case 'syllable': return syll(a) - syll(b) || label(a)
|
|
107
|
-
case 'syllable_desc': return syll(b) - syll(a) || label(a)
|
|
108
|
-
case 'grade': return grade(a) - grade(b) || label(a)
|
|
109
|
-
case 'grade_desc': return grade(b) - grade(a) || label(a)
|
|
109
|
+
case 'ganada': return 한글순(label(a), label(b));
|
|
110
|
+
case 'ganada_desc': return 한글순(label(b), label(a));
|
|
111
|
+
case 'syllable': return syll(a) - syll(b) || 한글순(label(a), label(b));
|
|
112
|
+
case 'syllable_desc': return syll(b) - syll(a) || 한글순(label(a), label(b));
|
|
113
|
+
case 'grade': return grade(a) - grade(b) || 한글순(label(a), label(b));
|
|
114
|
+
case 'grade_desc': return grade(b) - grade(a) || 한글순(label(a), label(b));
|
|
110
115
|
// 받은 순서 — Array.prototype.sort 는 안정 정렬이라 0 이면 받은 차례가 남는다
|
|
111
116
|
default: return 0;
|
|
112
117
|
}
|
|
@@ -197,6 +197,9 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
199
|
scale();
|
|
200
|
+
/* 안-모음: 인쇄 창에 끼워 넣는 글이고, 아래 refit 과 함께 beforeprint 에서도 불린다 —
|
|
201
|
+
인쇄 배치를 뜨는 순간이라 미루면 **옛 배율로 인쇄된다**. resize 는 사람이 미리보기 창을
|
|
202
|
+
끄는 때뿐이라 잦지 않다. */
|
|
200
203
|
window.addEventListener('resize', scale);
|
|
201
204
|
|
|
202
205
|
/* **칸 글씨를 이 종이 칸에 맞춰 다시 센다.**
|
|
@@ -226,6 +229,8 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
226
229
|
});
|
|
227
230
|
}
|
|
228
231
|
refit();
|
|
232
|
+
/* 안-모음: 바로 아래 beforeprint·print 매체질의가 **같은 함수**를 부른다 — 그 자리는 인쇄 배치가
|
|
233
|
+
잡히는 순간이라 못 미룬다(미루면 옛 배율로 인쇄된다, Letter 실측 -3.4% 가 그 꼴이었다). */
|
|
229
234
|
window.addEventListener('resize', refit);
|
|
230
235
|
/* 인쇄 배치로 바뀌는 순간을 브라우저마다 다른 신호로 알린다 — 크롬은 beforeprint,
|
|
231
236
|
파이어폭스는 print 매체 질의가 먼저 켜진다. 둘 다 걸어 두고 두 번 세도 값이 같다. */
|
|
@@ -317,22 +317,48 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
317
317
|
거기서는 막을 수 없어, 익은 순간 직접 단다(passive:false). */
|
|
318
318
|
const 스크롤막기 = (ev) => { if (익음 && ev.cancelable)
|
|
319
319
|
ev.preventDefault(); };
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
320
|
+
/* **길이 둘이라 갈라 둔다**(2026-10-10, 코어 자체 조사). *문턱을 넘었나* 는 **안 미룬다** — 그 판정이
|
|
321
|
+
길게누름을 거두는 일이라, 한 틀 미루면 손가락을 움직였는데도 끌기가 익어 버린다. *밑에 어느 칸인가*
|
|
322
|
+
는 `elementFromPoint`(레이아웃을 읽는다) + 상태 바꾸기라 **틀에 한 번으로 모은다** — 보이는 일뿐이다. */
|
|
323
|
+
let 자리예약 = null;
|
|
324
|
+
let 마지막점 = null;
|
|
325
|
+
const 자리셈 = () => {
|
|
326
|
+
const pt = 마지막점;
|
|
327
|
+
if (!pt)
|
|
324
328
|
return;
|
|
325
|
-
}
|
|
326
329
|
/* 손가락 밑의 칸. `elementFromPoint` 가 없는 환경(구형·시험)에서는 자리를 못 고른다 —
|
|
327
330
|
그때는 떨어뜨려도 아무 일이 없다(끌기가 시작되지 않은 것과 같다). */
|
|
328
|
-
const 밑 = document.elementFromPoint?.(
|
|
331
|
+
const 밑 = document.elementFromPoint?.(pt.x, pt.y);
|
|
329
332
|
const 칸 = 밑?.closest?.('[data-wordtable-cell]');
|
|
330
333
|
const i = 칸 ? Number(칸.dataset.wordtableCell) : NaN;
|
|
331
334
|
const 자리 = Number.isInteger(i) ? i : null;
|
|
332
335
|
떨굴자리.current = 자리;
|
|
333
|
-
setDragOver(자리);
|
|
336
|
+
setDragOver(prev => (prev === 자리 ? prev : 자리));
|
|
337
|
+
};
|
|
338
|
+
const 이동 = (ev) => {
|
|
339
|
+
if (!익음) {
|
|
340
|
+
if (Math.abs(ev.clientX - 시작.x) > 움직임문턱_PX || Math.abs(ev.clientY - 시작.y) > 움직임문턱_PX)
|
|
341
|
+
해제();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
마지막점 = { x: ev.clientX, y: ev.clientY };
|
|
345
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
346
|
+
자리셈();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (자리예약 !== null)
|
|
350
|
+
return;
|
|
351
|
+
자리예약 = requestAnimationFrame(() => { 자리예약 = null; 자리셈(); });
|
|
334
352
|
};
|
|
335
353
|
const 뗌 = () => {
|
|
354
|
+
/* **떼기 전에 밀린 셈을 치른다.** 자리셈을 틀로 모았으니, 움직이고 **같은 틀 안에** 떼면 틀이
|
|
355
|
+
오기 전에 여기 닿는다 — 그러면 `떨굴자리` 가 비어 **떨어뜨린 자리를 잃는다.** 옛 시험
|
|
356
|
+
(`wordTableReorder0916`)이 그것을 짚었다. 모으는 고침을 할 때는 **그 값을 읽는 쪽**도 봐야 한다. */
|
|
357
|
+
if (자리예약 !== null) {
|
|
358
|
+
cancelAnimationFrame(자리예약);
|
|
359
|
+
자리예약 = null;
|
|
360
|
+
자리셈();
|
|
361
|
+
}
|
|
336
362
|
const 자리 = 떨굴자리.current;
|
|
337
363
|
const 끌었다 = 익음;
|
|
338
364
|
해제();
|
|
@@ -344,6 +370,8 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
344
370
|
set끌기(index);
|
|
345
371
|
떨굴자리.current = index;
|
|
346
372
|
setDragOver(index);
|
|
373
|
+
/* **안-모음: `preventDefault()` 는 못 미룬다** — 틀로 미루면 이벤트가 이미 지나가 막을 수 없다
|
|
374
|
+
(`passive: false` 로 다는 까닭이 그것이다). */
|
|
347
375
|
window.addEventListener('touchmove', 스크롤막기, { passive: false });
|
|
348
376
|
}, 길게누름_MS);
|
|
349
377
|
function 해제() {
|
|
@@ -353,6 +381,10 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
|
|
|
353
381
|
window.removeEventListener('pointercancel', 뗌);
|
|
354
382
|
window.removeEventListener('touchmove', 스크롤막기);
|
|
355
383
|
익음 = false;
|
|
384
|
+
if (자리예약 !== null) {
|
|
385
|
+
cancelAnimationFrame(자리예약);
|
|
386
|
+
자리예약 = null;
|
|
387
|
+
}
|
|
356
388
|
떨굴자리.current = null;
|
|
357
389
|
풀기.current = null;
|
|
358
390
|
set끌기(null);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect } from 'react';
|
|
3
|
+
import { useEffect, useMemo } from 'react';
|
|
4
4
|
import i18n, { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../i18n';
|
|
5
5
|
import { I18nContext } from './I18nContext';
|
|
6
6
|
function 지원코드(locale) {
|
|
@@ -20,5 +20,14 @@ export function I18nProvider({ locale = DEFAULT_LOCALE, children }) {
|
|
|
20
20
|
i18n.changeLanguage(code);
|
|
21
21
|
}
|
|
22
22
|
}, [code]);
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* **값을 기억해 둔다** — (114) 와 **같은 꼴, 둘째 자리**다(2026-10-10, 코어 자체 조사).
|
|
25
|
+
* (114) 로 `WordDataProvider` 를 고칠 때 **프로바이더를 세지 않았다** — 리터럴을 바로 넘기는 자리가
|
|
26
|
+
* 둘이었고 하나만 고쳤다. 이 프로바이더도 앱 전체를 감싸고, `useI18n()` 은 공개 훅이라 쓰는 쪽을 코어가 모른다.
|
|
27
|
+
*
|
|
28
|
+
* `code` 는 글자이고 `SUPPORTED_LOCALES` 는 모듈 상수라 **값은 늘 같은데 객체만 새로** 났다 —
|
|
29
|
+
* 가장 안 보이는 꼴이다(값이 틀리지 않으니 아무도 안 묻는다).
|
|
30
|
+
*/
|
|
31
|
+
const value = useMemo(() => ({ locale: code, supportedLocales: SUPPORTED_LOCALES }), [code]);
|
|
32
|
+
return (_jsx(I18nContext.Provider, { value: value, children: children }));
|
|
24
33
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
2
|
import { type WordDataContextValue } from './WordDataContext';
|
|
3
3
|
export declare function WordDataProvider({ words, loading, error, statusMessage, localVersion, remoteVersion, progress, clearAndRefetch, checkRemoteVersion, children, }: WordDataContextValue & {
|
|
4
4
|
children: ReactNode;
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo } from 'react';
|
|
2
4
|
import { WordDataContext } from './WordDataContext';
|
|
3
5
|
export function WordDataProvider({ words, loading, error, statusMessage, localVersion, remoteVersion, progress, clearAndRefetch, checkRemoteVersion, children, }) {
|
|
4
|
-
|
|
6
|
+
/**
|
|
7
|
+
* **값을 기억해 둔다**(말귀 요청 (114), 2026-10-10). 전에는 객체 리터럴을 바로 넘겨 **그릴 때마다 새
|
|
8
|
+
* 객체**였다 — 값이 하나도 안 바뀌어도 `useContext(WordDataContext)` 를 쓰는 **열한 자리가 다 다시**
|
|
9
|
+
* 그렸다. 이 프로바이더는 앱 전체를 감싼다.
|
|
10
|
+
*
|
|
11
|
+
* 앱에서는 React 컴파일러가 이런 자리를 메워 주지만 **코어 묶음에는 컴파일러가 안 닿는다**
|
|
12
|
+
* (말귀 실측: `dist` 에 `_c(` 0건) — 그래서 앱 코드로는 흠이 아닌 것이 **여기서는 흠이다.**
|
|
13
|
+
* 소비자가 어떤 빌드를 쓰는지 코어는 모르니, 코어는 손으로 적는다.
|
|
14
|
+
*/
|
|
15
|
+
const value = useMemo(() => ({ words, loading, error, statusMessage, localVersion, remoteVersion, progress, clearAndRefetch, checkRemoteVersion }), [words, loading, error, statusMessage, localVersion, remoteVersion, progress, clearAndRefetch, checkRemoteVersion]);
|
|
16
|
+
return (_jsx(WordDataContext.Provider, { value: value, children: children }));
|
|
5
17
|
}
|
|
@@ -89,11 +89,26 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
89
89
|
document.addEventListener('mousedown', handleOutsideClick);
|
|
90
90
|
// iOS 는 누를 수 없는 요소를 톡하면 mousedown 을 안 보낸다 — pointerdown 으로도 닫는다(Tooltip·CategoryIcon 과 같게)
|
|
91
91
|
document.addEventListener('pointerdown', handleOutsideClick);
|
|
92
|
+
/* **자리 다시 재기는 틀에 한 번으로 모은다** — (115) 와 같은 꼴, 둘째 자리(2026-10-10, 코어 자체 조사).
|
|
93
|
+
`recalc()` 은 `getBoundingClientRect()` 로 레이아웃을 읽는데, 이 듣는 이는 **문서 전체의 굴림을
|
|
94
|
+
capture 로** 받는다 — `DatePicker` 가 `onScroll: 'reposition'` 을 주므로 달력이 열린 동안 굴리면
|
|
95
|
+
틀마다 여러 번 돌았다. **닫는 길('close')은 안 미룬다** — 미루면 앵커에서 떨어진 판이 한 틀 더
|
|
96
|
+
허공에 남는다(Tooltip 과 같은 가름). 미루는 것은 *자리* 뿐이다. */
|
|
97
|
+
let 자리예약 = null;
|
|
98
|
+
const 자리다시 = () => {
|
|
99
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
100
|
+
recalc();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (자리예약 !== null)
|
|
104
|
+
return;
|
|
105
|
+
자리예약 = requestAnimationFrame(() => { 자리예약 = null; recalc(); });
|
|
106
|
+
};
|
|
92
107
|
const handleScroll = (e) => {
|
|
93
108
|
if (menuRef.current?.contains(e.target))
|
|
94
109
|
return;
|
|
95
110
|
if (onScroll === 'reposition')
|
|
96
|
-
|
|
111
|
+
자리다시();
|
|
97
112
|
else if (onScroll === 'close')
|
|
98
113
|
setOpen(false);
|
|
99
114
|
};
|
|
@@ -101,7 +116,7 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
101
116
|
window.addEventListener('scroll', handleScroll, true);
|
|
102
117
|
const handleResize = () => {
|
|
103
118
|
if (onResize === 'reposition')
|
|
104
|
-
|
|
119
|
+
자리다시();
|
|
105
120
|
else if (onResize === 'close')
|
|
106
121
|
setOpen(false);
|
|
107
122
|
};
|
|
@@ -119,6 +134,8 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
119
134
|
window.addEventListener('orientationchange', handleOrientation);
|
|
120
135
|
}
|
|
121
136
|
return () => {
|
|
137
|
+
if (자리예약 !== null)
|
|
138
|
+
cancelAnimationFrame(자리예약);
|
|
122
139
|
판관찰?.disconnect();
|
|
123
140
|
document.removeEventListener('mousedown', handleOutsideClick);
|
|
124
141
|
document.removeEventListener('pointerdown', handleOutsideClick);
|