@ihabdevteam/core 0.147.0 → 0.148.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.
@@ -1388,7 +1388,7 @@ export declare function copyText(text: string): Promise<boolean>;
1388
1388
  *
1389
1389
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1390
1390
  */
1391
- export declare const CORE_VERSION = "0.147.0";
1391
+ export declare const CORE_VERSION = "0.148.0";
1392
1392
 
1393
1393
  /** @public */
1394
1394
  declare const coreDefault: Record<string, unknown>;
@@ -8943,7 +8943,6 @@ export declare interface WordSearchResultWord {
8943
8943
  */
8944
8944
  export declare function WordTable({ title, titleIcon, disc, className, orientation, contentTypes, cutLine, showOutline, showDivLine, items, editCells, count, activeCell, onActivateCell, onRemoveCell, onRemoveItem, onReorder, showFillerAdd, contentRef, buttonSize, pageNumber, pageNumberPosition, fitHeight, scoring, marks, onMarkClick, scoreIndexOffset, }: WordTableProps): JSX.Element;
8945
8945
 
8946
- /** @public */
8947
8946
  export declare function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }: WordTableBlockProps): JSX.Element;
8948
8947
 
8949
8948
  /** @public */
@@ -40,16 +40,25 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
40
40
  ro.observe(el);
41
41
  return () => ro.disconnect();
42
42
  }, []);
43
+ /* 잰 폭은 "…"(구분선 1px + 52px 칸)가 서 있으면 그만큼 줄어든 값이다. 그 폭으로만 판정하면 같은 폭·같은 항목인데 지나온 길에 따라
44
+ 넘침이 달랐다(13개 매니저 메뉴 → 8개 말귀 메뉴로 바꾸면 들어갈 8개 중 하나가 "…" 에 갇힘). 유틸이 없을 때 지난 렌더에 "…" 가
45
+ 서 있었다면(넘침 때문이든 그때의 유틸 때문이든) 그 폭을 되돌려 **"…" 없는 폭** 으로 판정하고, 넘치면 "…" 자리를 뺀 폭으로 센다. */
46
+ const MORE_W = BTN_SIZE + 1;
47
+ const 더보기섰음Ref = useRef(false);
48
+ const 온폭 = containerWidth > 0 && moreItems.length === 0 && 더보기섰음Ref.current ? containerWidth + MORE_W : containerWidth;
49
+ const 다들어감 = itemsLen * (BTN_SIZE + BTN_GAP) - BTN_GAP <= 온폭;
43
50
  // n개 버튼: n*52 + (n-1)*10 <= W → n <= (W + 10) / 62
44
- const n = containerWidth > 0
45
- ? Math.floor((containerWidth + BTN_GAP) / (BTN_SIZE + BTN_GAP))
46
- : itemsLen;
51
+ const n = containerWidth <= 0 ? itemsLen
52
+ : moreItems.length > 0 ? Math.floor((containerWidth + BTN_GAP) / (BTN_SIZE + BTN_GAP))
53
+ : 다들어감 ? itemsLen
54
+ : Math.floor((온폭 - MORE_W + BTN_GAP) / (BTN_SIZE + BTN_GAP));
47
55
  const overflow = containerWidth > 0 && n < itemsLen;
48
56
  const maxVisible = overflow ? n : itemsLen;
49
57
  const visibleItems = items.slice(0, maxVisible);
50
58
  const hiddenItems = items.slice(maxVisible);
51
59
  // 넘친 nav 항목이 있거나 유틸(moreItems)이 있으면 더보기(…) 버튼을 노출
52
60
  const showMore = overflow || moreItems.length > 0;
61
+ useEffect(() => { 더보기섰음Ref.current = showMore; });
53
62
  const computeMenuStyle = useCallback((rect) => ({
54
63
  position: 'fixed',
55
64
  bottom: window.innerHeight - rect.top + 8,
@@ -66,7 +75,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
66
75
  const nameWithCount = useCallback((label, count) => count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label, [t]);
67
76
  const handleModeChange = useCallback(() => onAppModeChange?.(nextMode), [onAppModeChange, nextMode]);
68
77
  // 넘친 nav 항목 또는 유틸 항목이 현재 활성 상태면 … 버튼에 그 아이콘/선택표시를 반영
69
- const activeMore = useMemo(() => hiddenItems.find(it => it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
78
+ const activeMore = useMemo(() => hiddenItems.find(it => !!it.key && it.key === activeKey) ?? moreItems.find(it => it.selected || (it.key && it.key === activeKey)), [hiddenItems, activeKey, moreItems]);
70
79
  // 넘친 nav 항목 + 유틸 항목을 한 컬럼으로 합쳐 더보기 메뉴에 노출(각자 아이콘으로 구분)
71
80
  const moreColumns = useMemo(() => [{
72
81
  items: [
@@ -74,7 +83,7 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
74
83
  label: it.label,
75
84
  icon: it.icon,
76
85
  badgeCount: it.badgeCount,
77
- selected: it.key === activeKey,
86
+ selected: !!it.key && it.key === activeKey,
78
87
  closeOnSelect: true,
79
88
  // "…" 로 넘어간 꺼진 항목도 꺼진 채로 — 줄에서는 막히는데 메뉴에서는 골라졌다
80
89
  disabled: it.state === 'disabled',
@@ -105,6 +114,6 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
105
114
  const count = badgeOf(it.badgeCount);
106
115
  return (
107
116
  // key 는 선택 prop 이다 — 없으면 자리 번호로(형제 key 가 모두 undefined 가 되던 것)
108
- _jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
117
+ _jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: !!it.key && it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
109
118
  }) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: nameWithCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
110
119
  }
@@ -8,7 +8,9 @@ import Button from './Button';
8
8
  /** @public */
9
9
  export default function SegmentedControl(props) {
10
10
  const { t } = useTranslation();
11
- const { options = [], value = options[0]?.key, onChange = (_v) => { }, className = '', size = 48, shape = 'pill', useBG = true, fullWidth = false, ariaLabel, } = props || {};
11
+ const { options = [],
12
+ // 안 주면 **켜진** 첫 칸 — 꺼진 첫 칸이 골라져 있었다(Tabs 와 같게)
13
+ value = options.find((o) => !o.disabled)?.key ?? options[0]?.key, onChange = (_v) => { }, className = '', size = 48, shape = 'pill', useBG = true, fullWidth = false, ariaLabel, } = props || {};
12
14
  /* **라디오 무리는 Tab 자리 하나 + 화살표**가 ARIA 규칙이다.
13
15
  `role="radiogroup"`·`role="radio"` 만 붙여 놓고 키를 안 만들면 **안 붙인 것보다
14
16
  나쁘다** — 낭독기가 "라디오 그룹, 3 중 2" 라고 읽어 주니 사람은 화살표를 누르는데
@@ -63,7 +63,7 @@ export default function SideNav(props) {
63
63
  const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
64
64
  return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
65
65
  .filter((it) => !it.section && (it.key || it.label))
66
- .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
66
+ .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: !!it.key && it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
67
67
  if (it.disabled)
68
68
  return;
69
69
  if (it.href) {
@@ -19,7 +19,11 @@ export default function TopBanner({ children, interval = 5000, dotTone, classNam
19
19
  const slides = Children.toArray(children);
20
20
  const count = slides.length;
21
21
  const [index, setIndex] = useState(0);
22
- const [paused, setPaused] = useState(false);
22
+ /* 마우스 머무름과 포커스를 따로 든다 — 한 값으로 들면 포커스가 든 채 마우스가 스치고 나가면 멈춤이 풀려 포커스가 든 장이 넘어가
23
+ inert 가 되고 포커스가 body 로 떨어졌다(반대로 마우스 아래에서 포커스만 나가도 넘어갔다) */
24
+ const [hovered, setHovered] = useState(false);
25
+ const [focused, setFocused] = useState(false);
26
+ const paused = hovered || focused;
23
27
  // 슬라이드 수가 줄어도 범위를 벗어나지 않도록
24
28
  const current = count > 0 ? index % count : 0;
25
29
  useEffect(() => {
@@ -30,7 +34,8 @@ export default function TopBanner({ children, interval = 5000, dotTone, classNam
30
34
  }, [count, interval, paused]);
31
35
  if (count === 0)
32
36
  return null;
33
- return (_jsxs("section", { className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), children: [_jsx("div", { className: "top-banner__viewport", children: _jsx("div", { className: "top-banner__track", style: { transform: `translateX(-${current * 100}%)` }, children: slides.map((slide, i) => (_jsx("div", { className: "top-banner__slide", "aria-hidden": i !== current, ...(i !== current ? { inert: true } : {}), children: slide }, i))) }) }), count > 1 && (_jsx("div", { className: `top-banner__dots${(dotTone?.[current] ?? 'light') === 'dark' ? ' top-banner__dots--dark' : ''}`, children: slides.map((_, i) => (_jsx("button", { type: "button", className: `top-banner__dot${i === current ? ' top-banner__dot--active' : ''}`, "aria-label": t('topBanner.dotAriaLabel', {
37
+ return (_jsxs("section", { className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onFocus: () => setFocused(true), onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
38
+ setFocused(false); }, children: [_jsx("div", { className: "top-banner__viewport", children: _jsx("div", { className: "top-banner__track", style: { transform: `translateX(-${current * 100}%)` }, children: slides.map((slide, i) => (_jsx("div", { className: "top-banner__slide", "aria-hidden": i !== current, ...(i !== current ? { inert: true } : {}), children: slide }, i))) }) }), count > 1 && (_jsx("div", { className: `top-banner__dots${(dotTone?.[current] ?? 'light') === 'dark' ? ' top-banner__dots--dark' : ''}`, children: slides.map((_, i) => (_jsx("button", { type: "button", className: `top-banner__dot${i === current ? ' top-banner__dot--active' : ''}`, "aria-label": t('topBanner.dotAriaLabel', {
34
39
  count, index: i + 1, defaultValue: `${count}개 중 ${i + 1}번째 배너`,
35
40
  }), "aria-current": i === current, onClick: () => setIndex(i) }, i))) }))] }));
36
41
  }
@@ -1,5 +1,4 @@
1
1
  import './WordTableBlock.css';
2
2
  import type { WordTableBlockProps } from './wordTableBlockTypes';
3
3
  export type { WordEntry, WordTableBlockProps } from './wordTableBlockTypes';
4
- /** @public */
5
4
  export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }: WordTableBlockProps): import("react").JSX.Element;
@@ -23,6 +23,28 @@ import { useWordTablePrint } from './useWordTablePrint';
23
23
  import { CELL_COUNTS, ITEMS_PER_PAGE_OPTIONS } from './wordTableBlockTypes';
24
24
  // ── 컴포넌트 ───────────────────────────────────────────────
25
25
  /** @public */
26
+ /** 편집 칸의 낱말 → 저장 항목의 낱말. 편집 칸에서 보이던 구어체 발음·등급·품사·유형을 그대로 옮긴다. */
27
+ function 항목낱말(e) {
28
+ return {
29
+ label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
30
+ grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType,
31
+ };
32
+ }
33
+ /** 동음이의어 번호까지 넣은 낱말들로 만든 id — 첫 낱말만 쓰면 '배'(1)·'배'(2), '가다|나다'·'가다|다다' 가 같은 id 였다 */
34
+ function 항목id(entries) {
35
+ return entries.map(e => (e.homonum != null ? `${e.word}#${e.homonum}` : e.word)).join('|');
36
+ }
37
+ /** 표 안에서 같은 낱말을 두 칸에 넣었을 때도 id 가 겹치지 않게 뒤에 ~2, ~3 을 붙인다 */
38
+ function 겹치지않는id(list) {
39
+ const 쓴 = new Set();
40
+ return list.map(it => {
41
+ let id = it.id;
42
+ for (let n = 2; 쓴.has(id); n++)
43
+ id = `${it.id}~${n}`;
44
+ 쓴.add(id);
45
+ return id === it.id ? it : { ...it, id };
46
+ });
47
+ }
26
48
  export default function WordTableBlock({ title, titleIcon, disc, items, onSaveComplete, onLoad, onRemoveItem, onReset, resetLabel, saveButtonLabel, secondarySaveButtonLabel, onReorder, showCellCount, onAddItem, className, renderSavePopup, renderWordSearch, hideControls, saveOnly, tableName, nameValue, onNameChange, descriptionValue, onDescriptionChange, namePlaceholder, descriptionPlaceholder, existingNames, editingName, onCancel }) {
27
49
  const { t } = useTranslation();
28
50
  const resolvedResetLabel = resetLabel ?? t('wordPrint.clearAllSelection', '모두 선택 해제');
@@ -53,6 +75,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
53
75
  const [cells, setCells] = useState(Array(4).fill(null));
54
76
  const [cellCount, setCellCount] = useState(4);
55
77
  const [activeCell, setActiveCell] = useState(null);
78
+ const visibleCells = cells.slice(0, cellCount);
56
79
  // 공통 단어 표 설정 상태
57
80
  const [orientation, setOrientation] = useState('landscape');
58
81
  const [contentTypes, setContentTypes] = useState(['이미지', '단어', '표준 발음']);
@@ -104,7 +127,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
104
127
  const pages = pagesEnabled ? chunkItems(items ?? [], itemsPerPage) : null;
105
128
  const isPaginated = !!pages && pages.length > 1;
106
129
  const hasPairs = isEditable
107
- ? cells.some(c => c !== null && c.length > 1)
130
+ ? visibleCells.some(c => c !== null && c.length > 1)
108
131
  : (items ?? []).some(item => item.words.length > 1);
109
132
  // PDF로 저장할 때 OS가 제안하는 기본 파일명. 저장된 표 이름이 있으면 그대로, 없으면 "오늘 날짜 + 단어표/대립쌍표"
110
133
  const printFileName = tableName?.trim()
@@ -118,13 +141,11 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
118
141
  onPopupBlocked: () => { showWarning(t('wordTable.popupBlocked', '팝업이 차단되었습니다. 브라우저 설정에서 팝업을 허용해 주세요.')); },
119
142
  });
120
143
  // 편집 모드 핸들러
144
+ /* 칸 수를 줄여도 뒤 칸의 낱말은 버리지 않고 들고 있는다 — 4칸→2칸→4칸에 셋째·넷째 낱말이 조용히 사라졌다.
145
+ 보이는(저장되는) 것은 앞의 cellCount 칸뿐이다. */
121
146
  const handleCellCountChange = (next) => {
122
147
  setCellCount(next);
123
- setCells(prev => {
124
- const arr = Array(next).fill(null);
125
- prev.slice(0, next).forEach((v, i) => { arr[i] = v; });
126
- return arr;
127
- });
148
+ setCells(prev => prev.length >= next ? prev : [...prev, ...Array(next - prev.length).fill(null)]);
128
149
  setActiveCell(null);
129
150
  };
130
151
  const handleRemove = (idx) => {
@@ -136,12 +157,13 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
136
157
  if (activeCell === null)
137
158
  return;
138
159
  const idx = activeCell;
139
- setCells(prev => { const next = [...prev]; next[idx] = entries; return next; });
140
- const nextEmpty = cells.findIndex((c, i) => i > idx && c === null);
160
+ // 빈 배열로 확인하면 비운 칸으로 둔다 — [] 를 담으면 저장 항목을 만들 때 c[0].word 에서 던졌다
161
+ setCells(prev => { const next = [...prev]; next[idx] = entries.length > 0 ? entries : null; return next; });
162
+ const nextEmpty = visibleCells.findIndex((c, i) => i > idx && c === null);
141
163
  setActiveCell(nextEmpty !== -1 ? nextEmpty : null);
142
164
  };
143
165
  const hasColloquial = isEditable
144
- ? cells.some(c => c?.some(e => !!e.colloquialPronunciation))
166
+ ? visibleCells.some(c => c?.some(e => !!e.colloquialPronunciation))
145
167
  : (items ?? []).some(item => item.words.some(w => !!w.colloquialLabel));
146
168
  // 표에 실제로 이미지가 있는지 — resolver(word)는 존재 여부와 무관하게 항상 URL을 만들어 반환하므로
147
169
  // 그 truthiness만으로는 판정할 수 없다(항상 true가 되어 이미지 없는 표에서도 '이미지' 토글이
@@ -153,9 +175,9 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
153
175
  if (!getImageResolver())
154
176
  return [];
155
177
  return isEditable
156
- ? cells.flatMap(c => (c ?? []).map(e => [e.word, e.homonum]))
178
+ ? cells.slice(0, cellCount).flatMap(c => (c ?? []).map(e => [e.word, e.homonum]))
157
179
  : (items ?? []).flatMap(item => item.words.map(w => [w.label, w.homonum]));
158
- }, [isEditable, cells, items]);
180
+ }, [isEditable, cells, cellCount, items]);
159
181
  /* 실제로 파일이 있는지는 물어봐야 안다(HEAD 요청 + 캐시). 그 답만 상태로 든다.
160
182
  `imagePairs` 가 비면 물어볼 것도 없으므로 아래 유도에서 곧바로 false 가 된다. */
161
183
  const [imageFound, setImageFound] = useState(false);
@@ -179,7 +201,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
179
201
  ...(isPaginated ? [buildPageNumberSection(t)] : []),
180
202
  ], [t, isEditable, showCellCount, pagesEnabled, items?.length, hasPairs, itemCount, isPaginated]);
181
203
  const middleSelected = {
182
- ...((isEditable || showCellCount) ? { [t('wordTable.filter.columns', '칸 수')]: `${isEditable ? cellCount : displayCellCount}칸` } : {}),
204
+ ...((isEditable || showCellCount) ? { [t('wordTable.filter.columns', '칸 수')]: `${count}칸` } : {}),
183
205
  ...(pagesEnabled && (items?.length ?? 0) > 0 ? { [t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')]: `${effectiveItemsPerPage}개` } : {}),
184
206
  ...(isPaginated ? { [t('wordTable.filter.pageNumber', '페이지 번호')]: PAGE_NUM_POSITION_VALUE[pageNumberPosition] } : {}),
185
207
  };
@@ -187,8 +209,9 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
187
209
  if (sectionLabel === t('wordTable.filter.columns', '칸 수')) {
188
210
  if (isEditable)
189
211
  handleCellCountChange(parseInt(value));
212
+ // 항목 수보다 적게는 못 줄인다(그리드는 항목 수만큼 그린다) — 고른 값을 그대로 담으면 켜진 칸 수와 보이는 표가 달랐다
190
213
  else
191
- setDisplayCellCount(parseInt(value));
214
+ setDisplayCellCount(Math.max(parseInt(value), items?.length ?? 1));
192
215
  }
193
216
  else if (sectionLabel === t(hasPairs ? 'wordTable.filter.itemsPerPagePair' : 'wordTable.filter.itemsPerPageWord')) {
194
217
  /* 목록에 없는 개수(= 그때의 '전체' 칸)를 고르면 **전체** 로 기억한다 — 숫자로 담으면 항목이 늘었을 때 고른 값('10개')이
@@ -202,10 +225,10 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
202
225
  };
203
226
  // 저장 팝업용 현재 items / preview
204
227
  const saveItems = isEditable
205
- ? cells.filter((c) => c !== null).map(c => ({
206
- id: c[0].word,
207
- words: c.map(e => ({ label: e.word, subLabel: e.pronunciation, homonum: e.homonum })),
208
- }))
228
+ ? 겹치지않는id(visibleCells.filter((c) => c !== null && c.length > 0).map(c => ({
229
+ id: 항목id(c),
230
+ words: c.map(항목낱말),
231
+ })))
209
232
  : items ?? [];
210
233
  const handleSave = (asNew = false) => {
211
234
  setSaveAsNew(asNew);
@@ -279,10 +302,12 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
279
302
  if (onAddItem) {
280
303
  const entry = entries[0];
281
304
  if (entry) {
282
- onAddItem({
283
- id: entry.word,
284
- words: [{ label: entry.word, subLabel: entry.pronunciation, homonum: entry.homonum }],
285
- });
305
+ // 이미 있는 항목과 id 가 겹치면(같은 낱말의 다른 동음이의어·같은 낱말) onRemoveItem(id) 가 둘을 함께 지운다
306
+ const 쓴id = new Set((items ?? []).map(it => it.id));
307
+ let id = 항목id([entry]);
308
+ for (let n = 2; 쓴id.has(id); n++)
309
+ id = `${항목id([entry])}~${n}`;
310
+ onAddItem({ id, words: [항목낱말(entry)] });
286
311
  }
287
312
  }
288
313
  setActiveCell(null);
@@ -291,7 +316,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
291
316
  }), !hideControls && (_jsx(TableFilterPanel, { hasImage: hasImage, hasColloquial: hasColloquial, contentTypes: contentTypes, orientation: orientation, showOutline: showOutline, cutLine: cutLine, showDivLine: showDivLine, setters: { setContentTypes, setOrientation, setShowOutline, setCutLine, setShowDivLine }, showCutLine: (pagesEnabled ? itemsPerPage : count) > 1, showDivLine2: hasPairs, middleSections: middleSections, extraSelected: middleSelected, onExtraSelect: handleMiddleSelect })), isPaginated && pages ? (_jsx("div", { className: "word-table-block__pages", children: pages.map((page, i) => (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: itemsPerPage, items: page, onRemoveItem: onRemoveItem,
292
317
  /* 쪽이 나뉘면 끌어 옮기기가 통째로 꺼졌다(말귀 단어 찾기: 9개부터). 쪽 안의 자리를 전체 자리로 옮겨 넘긴다 —
293
318
  쪽을 넘는 이동은 하지 않는다. */
294
- onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? cells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
319
+ onReorder: onReorder && ((from, to) => onReorder(from + i * itemsPerPage, to + i * itemsPerPage)), contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
295
320
  /* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
296
321
  이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
297
322
  섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
@@ -461,8 +461,10 @@ preferAsTarget) {
461
461
  }
462
462
  const targetWords = allWords.filter(({ syls, wordSyls }) => {
463
463
  const filterSyls = !phonemic && wordSyls ? wordSyls : syls;
464
+ /* 일치(ordered) 는 조건의 자리가 곧 음절 자리다 — 빈 슬롯을 걸러 낸 reqs 를 쓰면 [{}, {초성:ㄴ}] 의 ㄴ 이 첫 음절에 걸렸다.
465
+ wordContainsAllPhonemes 는 빈 슬롯을 스스로 건너뛴다. */
464
466
  return ordered
465
- ? wordContainsAllPhonemes(filterSyls, reqs)
467
+ ? wordContainsAllPhonemes(filterSyls, requiredPhonemes)
466
468
  : wordContainsAllPhonemesUnordered(filterSyls, reqs);
467
469
  });
468
470
  const groups = new Map();
@@ -534,10 +536,12 @@ preferAsTarget) {
534
536
  if (tv !== cv)
535
537
  diffValues = [tv, cv];
536
538
  }
537
- const isRelevant = reqs.some(req => {
538
- const reqVal = req[diffSection];
539
+ const 맞나 = (req) => {
540
+ const reqVal = req?.[diffSection];
539
541
  return reqVal !== undefined && (diffValues[0] === reqVal || diffValues[1] === reqVal);
540
- });
542
+ };
543
+ // 일치(ordered): "첫번째 음절에서 초성 ㄱ 이 대립" — 대립한 자리의 조건이어야 한다(다른 음절에서 대립한 쌍이 섞였다)
544
+ const isRelevant = ordered ? 맞나(requiredPhonemes[analysis.diff.idx]) : reqs.some(맞나);
541
545
  if (!isRelevant)
542
546
  continue;
543
547
  }
@@ -545,6 +549,8 @@ preferAsTarget) {
545
549
  const diffVals = analysis.diff.diffs[diffSection];
546
550
  const diffHasEmpty = diffHasEmptyPair(diffSection, diffVals);
547
551
  const item = {
552
+ /* 정렬 꼴 그대로 — 기준 방향을 넣으면 음소를 ㄱ→ㄴ 으로 바꿔 찾거나 저장한 표를 다시 열 때 같은 쌍의 id 가 달라져
553
+ 바구니(id 로 견줌)에서 선택이 풀리고 두 번 담겼다. 이미 저장된 대립쌍표 id 도 이 꼴이다. */
548
554
  id: dedupKey,
549
555
  diffSection,
550
556
  diffHasEmpty,
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.147.0";
49
+ export declare const CORE_VERSION = "0.148.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.147.0';
49
+ export const CORE_VERSION = '0.148.0';