@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.
- package/CHANGELOG.md +24 -0
- package/dist/components.cjs.js +176 -176
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +1 -2
- package/dist/packages/core/src/components/BottomNav.js +15 -6
- package/dist/packages/core/src/components/SegmentedControl.js +3 -1
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/TopBanner.js +7 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.d.ts +0 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +47 -22
- package/dist/packages/core/src/utils/phonemePairSearch.js +10 -4
- 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
|
@@ -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.
|
|
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
|
|
45
|
-
? Math.floor((containerWidth + BTN_GAP) / (BTN_SIZE + BTN_GAP))
|
|
46
|
-
|
|
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 = [],
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
?
|
|
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
|
-
|
|
140
|
-
const
|
|
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
|
-
?
|
|
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', '칸 수')]: `${
|
|
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
|
-
?
|
|
206
|
-
id: c
|
|
207
|
-
words: c.map(
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 ?
|
|
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,
|
|
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
|
|
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,
|