@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.
@@ -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.311.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.localeCompare(b.name, 'ko'));
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
- const 재기 = () => {
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
- set막대({ top: Math.round((scrollTop / 남은굴림) * 남은길), height: Math.round(길이) });
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 () => { el.removeEventListener('scroll', 재기); ro?.disconnect(); };
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
- const onWindowChange = () => computePosition(true);
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
- // 정렬 비교자 — flat/그룹 뷰가 공유한다.
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) || 가나다.compare(label(a), label(b));
107
+ return 시작순위(a) - 시작순위(b) || 한글순(label(a), label(b));
103
108
  }
104
- case 'ganada': return label(a).localeCompare(label(b), 'ko');
105
- case 'ganada_desc': return label(b).localeCompare(label(a), 'ko');
106
- case 'syllable': return syll(a) - syll(b) || label(a).localeCompare(label(b), 'ko');
107
- case 'syllable_desc': return syll(b) - syll(a) || label(a).localeCompare(label(b), 'ko');
108
- case 'grade': return grade(a) - grade(b) || label(a).localeCompare(label(b), 'ko');
109
- case 'grade_desc': return grade(b) - grade(a) || label(a).localeCompare(label(b), 'ko');
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
- const 이동 = (ev) => {
321
- if (!익음) {
322
- if (Math.abs(ev.clientX - 시작.x) > 움직임문턱_PX || Math.abs(ev.clientY - 시작.y) > 움직임문턱_PX)
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?.(ev.clientX, ev.clientY);
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
- return (_jsx(I18nContext.Provider, { value: { locale: code, supportedLocales: SUPPORTED_LOCALES }, children: children }));
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 { ReactNode } from 'react';
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
- return (_jsx(WordDataContext.Provider, { value: { words, loading, error, statusMessage, localVersion, remoteVersion, progress, clearAndRefetch, checkRemoteVersion }, children: children }));
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
- recalc();
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
- recalc();
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);
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.311.0";
49
+ export declare const CORE_VERSION = "0.313.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.311.0';
49
+ export const CORE_VERSION = '0.313.0';