@ihabdevteam/core 0.203.0 → 0.205.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 +100 -0
- package/dist/components.cjs.js +11 -11
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +15 -15
- package/dist/ihabdevteam-core.d.ts +21 -2
- package/dist/packages/core/src/components/Word/WordTableBlock.css +15 -0
- package/dist/packages/core/src/components/Word/WordTableBlock.d.ts +1 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +19 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +45 -13
- 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
|
@@ -1626,7 +1626,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1626
1626
|
*
|
|
1627
1627
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1628
1628
|
*/
|
|
1629
|
-
export declare const CORE_VERSION = "0.
|
|
1629
|
+
export declare const CORE_VERSION = "0.205.0";
|
|
1630
1630
|
|
|
1631
1631
|
/** @public */
|
|
1632
1632
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -10326,7 +10326,7 @@ export declare interface WordSearchResultWord {
|
|
|
10326
10326
|
*/
|
|
10327
10327
|
export declare function WordTable({ title, titleIcon, disc, className, orientation, contentTypes, cutLine, showOutline, showDivLine, items, editCells, count, activeCell, onActivateCell, onRemoveCell, onRemoveItem, onReorder, renderCellAction, showFillerAdd, contentRef, buttonSize, pageNumber, pageNumberPosition, fitHeight, scoring, marks, onMarkClick, scoreIndexOffset, }: WordTableProps): JSX.Element;
|
|
10328
10328
|
|
|
10329
|
-
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, initialItemsPerPage, renderCellAction }: WordTableBlockProps): JSX.Element;
|
|
10329
|
+
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, initialItemsPerPage, renderCellAction, actionsLeftExtra }: WordTableBlockProps): JSX.Element;
|
|
10330
10330
|
|
|
10331
10331
|
/** @public */
|
|
10332
10332
|
export declare interface WordTableBlockProps {
|
|
@@ -10421,6 +10421,25 @@ export declare interface WordTableBlockProps {
|
|
|
10421
10421
|
* **표시 모드에만 간다** — 편집 모드(칸을 손으로 채우는 꼴)에는 얹을 항목이 없다.
|
|
10422
10422
|
*/
|
|
10423
10423
|
renderCellAction?: (index: number, item: WordSearchResultItem) => ReactNode;
|
|
10424
|
+
/**
|
|
10425
|
+
* **액션 줄 왼쪽 무리에 부르는 쪽이 덧붙이는 것** — '모두 선택 해제' 다음, '불러오기' 앞에 선다.
|
|
10426
|
+
*
|
|
10427
|
+
* 그 줄 왼쪽은 **표를 손보는 단추들**이고 뒤쪽(불러오기 · 새 창으로 보기)은 **표를 드나드는
|
|
10428
|
+
* 단추들**이다. 덧붙는 것이 앞 무리에 드는 것은 그 까닭이다 — '모두 다른 단어로' 처럼 표
|
|
10429
|
+
* 내용을 바꾸는 단추가 '표를 나가는' 단추들 사이에 끼면 무리가 안 읽힌다(말귀 요청 ㉕,
|
|
10430
|
+
* 2026-09-28). `onReset` 을 안 주면 맨 앞에 선다.
|
|
10431
|
+
*
|
|
10432
|
+
* **동작이 아니라 그릴 것을 받는다.** 화면마다 무엇을 얹을지 다르고(한쪽은 '모두 다른 단어로',
|
|
10433
|
+
* 다른 쪽은 '모두 다른 대립쌍으로'), 코어가 아는 것은 **어디에 놓느냐**뿐이다. 코어가 세우는
|
|
10434
|
+
* 단추와 같아 보이려면 `<Button size={40} type="bar">` 로 지으면 된다.
|
|
10435
|
+
*
|
|
10436
|
+
* `saveOnly` 에서는 안 그린다 — 그때 이 줄 왼쪽은 통째로 빈다.
|
|
10437
|
+
*
|
|
10438
|
+
* 이 자리가 나기 전에는 앱이 `.word-table-block__actions-left` 를 찾아 포털로 얹고 CSS
|
|
10439
|
+
* `order` 로 차례를 맞추고 있었다. 남의 마디에 얹는 것이라 **코어가 class 이름이나 마디를
|
|
10440
|
+
* 바꾸면 조용히 어긋난다** — 그 길을 닫으려고 낸 자리다.
|
|
10441
|
+
*/
|
|
10442
|
+
actionsLeftExtra?: ReactNode;
|
|
10424
10443
|
}
|
|
10425
10444
|
|
|
10426
10445
|
/**
|
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
justify-content: space-between;
|
|
14
14
|
align-items: center;
|
|
15
15
|
gap: var(--spacing-xs);
|
|
16
|
+
/* 모자라면 아랫줄로 내린다 — 안 그러면 오른쪽 무리가 판 밖으로 밀려 **'저장하기' 가 잘린다**
|
|
17
|
+
(실측 2026-09-28: 판 오른끝 947px 인데 저장하기 오른끝이 1078px 이었다). 왼쪽 무리가
|
|
18
|
+
늘 수 있게 되면서(`actionsLeftExtra`) 닿기 쉬워졌다. 넉넉하면 아무것도 안 바뀐다. */
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* 아랫줄로 내려가도 주 단추는 **오른쪽 끝**에 선다 — 사람이 '저장하기' 를 찾는 자리다.
|
|
23
|
+
한 줄에 다 설 때는 `space-between` 이 이미 하는 일이라 아무 일도 안 한다. */
|
|
24
|
+
.word-table-block__actions-right {
|
|
25
|
+
margin-left: auto;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
.word-table-block__actions-left,
|
|
@@ -31,6 +41,11 @@
|
|
|
31
41
|
display: grid;
|
|
32
42
|
grid-template-columns: repeat(2, 1fr);
|
|
33
43
|
}
|
|
44
|
+
/* 세로로 세울 때는 `margin-left: auto` 를 거둔다 — 가로가 곧 엇축이라 그 자동 여백이
|
|
45
|
+
`align-items: stretch` 를 이겨, 오른쪽 무리만 제 속 너비로 쪼그라든다. */
|
|
46
|
+
.word-table-block__actions-right {
|
|
47
|
+
margin-left: 0;
|
|
48
|
+
}
|
|
34
49
|
/* min-width 해제 — !important 불필요: 이 결합 선택자(2클래스)가 버튼 사이즈 클래스
|
|
35
50
|
(.button40--bar 등, 1클래스)보다 특이도가 이미 높아 소스 순서와 무관하게 우선 적용됨.
|
|
36
51
|
(minWidth prop을 인라인으로 넘기는 경우는 여기 해당 없음) */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import './WordTableBlock.css';
|
|
2
2
|
import type { WordTableBlockProps } from './wordTableBlockTypes';
|
|
3
3
|
export type { WordEntry, WordTableBlockProps } from './wordTableBlockTypes';
|
|
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, initialItemsPerPage, renderCellAction }: WordTableBlockProps): import("react").JSX.Element;
|
|
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, initialItemsPerPage, renderCellAction, actionsLeftExtra }: WordTableBlockProps): import("react").JSX.Element;
|
|
@@ -45,7 +45,7 @@ function 겹치지않는id(list) {
|
|
|
45
45
|
return id === it.id ? it : { ...it, id };
|
|
46
46
|
});
|
|
47
47
|
}
|
|
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, initialItemsPerPage, renderCellAction }) {
|
|
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, initialItemsPerPage, renderCellAction, actionsLeftExtra }) {
|
|
49
49
|
const { t } = useTranslation();
|
|
50
50
|
const resolvedResetLabel = resetLabel ?? t('wordPrint.clearAllSelection', '모두 선택 해제');
|
|
51
51
|
const isEditable = items === undefined;
|
|
@@ -341,5 +341,5 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
|
|
|
341
341
|
e.preventDefault();
|
|
342
342
|
onDescriptionChange?.(descriptionPlaceholder ?? '');
|
|
343
343
|
}
|
|
344
|
-
} }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
|
|
344
|
+
} }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && actionsLeftExtra, !saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
|
|
345
345
|
}
|
|
@@ -107,6 +107,25 @@ export interface WordTableBlockProps {
|
|
|
107
107
|
* **표시 모드에만 간다** — 편집 모드(칸을 손으로 채우는 꼴)에는 얹을 항목이 없다.
|
|
108
108
|
*/
|
|
109
109
|
renderCellAction?: (index: number, item: WordSearchResultItem) => ReactNode;
|
|
110
|
+
/**
|
|
111
|
+
* **액션 줄 왼쪽 무리에 부르는 쪽이 덧붙이는 것** — '모두 선택 해제' 다음, '불러오기' 앞에 선다.
|
|
112
|
+
*
|
|
113
|
+
* 그 줄 왼쪽은 **표를 손보는 단추들**이고 뒤쪽(불러오기 · 새 창으로 보기)은 **표를 드나드는
|
|
114
|
+
* 단추들**이다. 덧붙는 것이 앞 무리에 드는 것은 그 까닭이다 — '모두 다른 단어로' 처럼 표
|
|
115
|
+
* 내용을 바꾸는 단추가 '표를 나가는' 단추들 사이에 끼면 무리가 안 읽힌다(말귀 요청 ㉕,
|
|
116
|
+
* 2026-09-28). `onReset` 을 안 주면 맨 앞에 선다.
|
|
117
|
+
*
|
|
118
|
+
* **동작이 아니라 그릴 것을 받는다.** 화면마다 무엇을 얹을지 다르고(한쪽은 '모두 다른 단어로',
|
|
119
|
+
* 다른 쪽은 '모두 다른 대립쌍으로'), 코어가 아는 것은 **어디에 놓느냐**뿐이다. 코어가 세우는
|
|
120
|
+
* 단추와 같아 보이려면 `<Button size={40} type="bar">` 로 지으면 된다.
|
|
121
|
+
*
|
|
122
|
+
* `saveOnly` 에서는 안 그린다 — 그때 이 줄 왼쪽은 통째로 빈다.
|
|
123
|
+
*
|
|
124
|
+
* 이 자리가 나기 전에는 앱이 `.word-table-block__actions-left` 를 찾아 포털로 얹고 CSS
|
|
125
|
+
* `order` 로 차례를 맞추고 있었다. 남의 마디에 얹는 것이라 **코어가 class 이름이나 마디를
|
|
126
|
+
* 바꾸면 조용히 어긋난다** — 그 길을 닫으려고 낸 자리다.
|
|
127
|
+
*/
|
|
128
|
+
actionsLeftExtra?: ReactNode;
|
|
110
129
|
}
|
|
111
130
|
export declare const CELL_COUNTS: number[];
|
|
112
131
|
/** 표시(인쇄) 모드에서 선택 가능한 "페이지당 개수"(단어/대립쌍 수) — 이 값 그대로 페이지당 칸 수가 된다 */
|
|
@@ -323,6 +323,9 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
323
323
|
const parsed = Number.parseInt(g ?? '', 10);
|
|
324
324
|
return Number.isFinite(parsed) ? parsed : 99;
|
|
325
325
|
};
|
|
326
|
+
/* 묶음마다 '어느 자리를 비운 열쇠인가'(비운자리)를 함께 든다 — 견주기 전에 그 자리만 보고 거르기 위해서다.
|
|
327
|
+
열쇠 글에서 `*` 가 든 토막의 자리가 곧 그 자리이고, `*` 는 encodeWild 로만 들어가므로
|
|
328
|
+
한 묶음의 낱말은 모두 같은 자리를 비운 것이다. 분해한 음절도 함께 든다(견줄 때마다 다시 분해하지 않게). */
|
|
326
329
|
const groups = new Map();
|
|
327
330
|
/* 표기가 같아도 뜻마다 칸이 하나다 — 판정은 대표로 한 번만 하고 결과에서 뜻을 펼친다 */
|
|
328
331
|
const { 대표들, 뜻들 } = 뜻묶기(words);
|
|
@@ -336,28 +339,57 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
336
339
|
const syls = syllables;
|
|
337
340
|
for (let p = 0; p < syls.length; p++) {
|
|
338
341
|
const key = makeKeyForPosition(syls, p);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
+
let 묶음 = groups.get(key);
|
|
343
|
+
if (!묶음) {
|
|
344
|
+
묶음 = { 비운자리: p, 낱말들: [] };
|
|
345
|
+
groups.set(key, 묶음);
|
|
346
|
+
}
|
|
347
|
+
묶음.낱말들.push({ entry, syls });
|
|
342
348
|
}
|
|
343
349
|
}
|
|
344
350
|
const results = [];
|
|
345
|
-
const seenPairs = new Set();
|
|
346
351
|
const rejectCounts = createRejectCounter();
|
|
347
352
|
const rejectSamples = [];
|
|
348
353
|
let comparedPairs = 0;
|
|
349
|
-
for (const
|
|
350
|
-
if (
|
|
354
|
+
for (const { 비운자리, 낱말들 } of groups.values()) {
|
|
355
|
+
if (낱말들.length < 2)
|
|
351
356
|
continue;
|
|
352
|
-
for (let i = 0; i <
|
|
353
|
-
for (let j = i + 1; j <
|
|
354
|
-
const w1 =
|
|
355
|
-
const w2 =
|
|
356
|
-
|
|
357
|
-
|
|
357
|
+
for (let i = 0; i < 낱말들.length; i++) {
|
|
358
|
+
for (let j = i + 1; j < 낱말들.length; j++) {
|
|
359
|
+
const w1 = 낱말들[i].entry;
|
|
360
|
+
const w2 = 낱말들[j].entry;
|
|
361
|
+
/* 같은 표기가 발음만 다르게 두 번 들어온 자료(김밥[김밥]·김밥[김빱])는 짝으로 세지 않는다 —
|
|
362
|
+
칸 둘의 이름이 같은 '김밥/김밥' 쌍이 된다. 견준 쌍을 다 기억하던 전에는 이것이
|
|
363
|
+
덤으로 막히고 있었다(같은 표기면 열쇠가 하나라 한 번만 지나갔다). */
|
|
364
|
+
if (w1.word === w2.word)
|
|
358
365
|
continue;
|
|
359
|
-
|
|
366
|
+
/* **견준 쌍을 기억하지 않는다.** 전에는 모든 쌍을 `Set` 에 담아 겹침을 걸렀는데, 그 수가
|
|
367
|
+
낱말 수의 제곱이라 자바스크립트 `Set` 상한(2^24)에 닿아 6,200 낱말에서 화면이 통째로
|
|
368
|
+
넘어졌다(말귀 요청 ㉔, 2026-09-28). 기억하지 않아도 되는 까닭:
|
|
369
|
+
|
|
370
|
+
한 묶음 안의 두 낱말은 비운 자리 밖이 모두 같다. 그러니 다른 데가 정확히 한 군데인
|
|
371
|
+
쌍 — 곧 최소대립쌍이 될 수 있는 쌍 — 은 비운 자리가 바로 그 자리인 묶음 **하나에만**
|
|
372
|
+
든다(다른 자리를 비운 묶음은 그 자리가 같기를 요구하므로 둘 다 들 수 없다). 그래서
|
|
373
|
+
한 번씩만 지나간다. 두 번 지나가는 쌍은 발음이 아주 같은 쌍뿐이고, 그것은 아래
|
|
374
|
+
`no_difference` 로 떨어져 결과에 못 든다.
|
|
375
|
+
|
|
376
|
+
남은 것은 '한 군데만 다른가' 를 쌍 문자열·발음 분해 전에 여기서 재는 일이다 —
|
|
377
|
+
analyzeMinimalPair 와 같은 잣대(SECTION_KEYS)를 그 한 자리에만 쓴다. 포함 검색
|
|
378
|
+
(searchWordsByPhonemes)이 전부터 하던 것과 같은 걸름이다. */
|
|
379
|
+
const s1 = 낱말들[i].syls[비운자리];
|
|
380
|
+
const s2 = 낱말들[j].syls[비운자리];
|
|
381
|
+
let 다른칸수 = 0;
|
|
382
|
+
for (const sec of SECTION_KEYS)
|
|
383
|
+
if (s1[sec] !== s2[sec])
|
|
384
|
+
다른칸수 += 1;
|
|
360
385
|
comparedPairs += 1;
|
|
386
|
+
if (다른칸수 !== 1) {
|
|
387
|
+
const reason = 다른칸수 === 0 ? 'no_difference' : 'multiple_differences';
|
|
388
|
+
rejectCounts[reason] += 1;
|
|
389
|
+
if (debug && rejectSamples.length < 20)
|
|
390
|
+
rejectSamples.push({ id: `${w1.word}|${w2.word}`, reason });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
361
393
|
const pairId = `${w1.word}|${w2.word}`;
|
|
362
394
|
const analysis = analyzeMinimalPair(cleanPron(w1.pronunciation), cleanPron(w2.pronunciation), phonemic);
|
|
363
395
|
if (analysis.ok && enforcePosition !== undefined && analysis.diff.idx !== enforcePosition)
|