From a7c48aae9aa25c67945da0b6a7b94c3b10ac0efe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 22:06:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(829-1):=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EC=B9=B8=20=EB=90=98=ED=92=80=EC=9D=B4=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?=EB=8A=A6=EC=9D=80=20=EC=98=9B=20=EC=9D=91=EB=8B=B5=20=EB=8D=AE?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EA=B8=B0=20=EB=A7=89=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EC=A1=B0=ED=95=A9=20=EC=A4=91=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EB=B3=B4=EB=A5=98=20=C2=B7=20=ED=82=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EB=86=92=EC=9D=B4=EB=A7=8C=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EB=92=A4=20=EC=A4=84=20=EC=88=98=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EA=B8=B0=20=EA=B1=B0=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인 셋: ① 목록 불러오기 응답 순서 가드 없음(Rows · Machine · Tables · 복사 고르기 · 고를 재료 판) → 느린 회선에서 앞 글자 응답이 뒤 글자 목록을 덮음 ② 글자 칸 조합마다 input 이 검색을 걸음 ③ 키보드가 뜨고 닫을 때마다 watchFit 이 줄 수를 다시 재 목록을 다시 받아 그림 · 첫 그림 어림(6)과 잰 값(5) 어긋남으로 한 글자에 두 번 받음 - 공용 고침: ui_template_pager(holdForKeyboard · 직전 잰 줄 수 어림) · device_context(찾기 칸 조합 보류) · M01 Rows · Machine · Tables · Page · Pick · LogicLab_Copy - 시험: test_709_pager_fit 에 holdForKeyboard 1 건 더함 Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/device_context.ts | 18 +++++++++++++++ .../M01_MasterData_UI_LogicLab_Copy.ts | 3 +++ M01_MasterData/M01_MasterData_UI_Machine.ts | 7 +++++- M01_MasterData/M01_MasterData_UI_Page.ts | 4 +++- M01_MasterData/M01_MasterData_UI_Pick.ts | 3 +++ M01_MasterData/M01_MasterData_UI_Rows.ts | 7 +++++- M01_MasterData/M01_MasterData_UI_Tables.ts | 6 +++++ resources/tester/test_709_pager_fit.py | 10 ++++++++ ui_template/ui_template_pager.ts | 23 +++++++++++++++++++ 9 files changed, 78 insertions(+), 3 deletions(-) diff --git a/A00_Common/device_context.ts b/A00_Common/device_context.ts index cf07adf04..b9648dcb5 100644 --- a/A00_Common/device_context.ts +++ b/A00_Common/device_context.ts @@ -10,6 +10,7 @@ * - data-burger = on · off — 공개 쪽에서 ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) · 로그인 뒤 쪽(B · M)은 늘 ☰(7-1) * 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태). * 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수. + * 찾기 칸(`input[type=search]`) — 한글 조합 중 `input` 은 칸의 처리기에 안 보내고 조합이 끝나면 한 번 보냄(829-1 · 모든 찾기 칸이 한 곳에서). * 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘. * ========================================================================== */ @@ -111,6 +112,23 @@ function init(): void { window.addEventListener("resize", apply); window.visualViewport?.addEventListener("resize", apply); screen.orientation?.addEventListener("change", apply); + // 문서 맨 앞(capture)에서 가로챔 — 칸에 붙은 처리기(조합마다 검색을 거는)가 조합이 끝난 뒤 한 번만 불림 + const isSearch = (e: Event): boolean => + e.target instanceof HTMLInputElement && e.target.type === "search"; + document.addEventListener( + "input", + (e) => { + if ((e as InputEvent).isComposing && isSearch(e)) e.stopImmediatePropagation(); + }, + true, + ); + document.addEventListener( + "compositionend", + (e) => { + if (isSearch(e)) e.target!.dispatchEvent(new Event("input", { bubbles: true })); + }, + true, + ); window.addEventListener( "pointerdown", (e) => { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts index ec162d843..18388b853 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Copy.ts @@ -93,11 +93,14 @@ function openTablePick(onPick: (table: TableHead) => void): void { }); let timer: number | undefined; + let seq = 0; // 늦게 온 옛 응답은 버림(829-1) const run = async (): Promise => { + const mine = ++seq; const q = search.input.value.trim(); const pages = await Promise.all( TABLE_GROUPS.map((group) => fetchTables(group, "", "", q, 1, 20)), ); + if (mine !== seq) return; const hits = pages.flatMap((p) => p.tables); list.replaceChildren( ...(hits.length diff --git a/M01_MasterData/M01_MasterData_UI_Machine.ts b/M01_MasterData/M01_MasterData_UI_Machine.ts index 14c218259..0f723db3b 100644 --- a/M01_MasterData/M01_MasterData_UI_Machine.ts +++ b/M01_MasterData/M01_MasterData_UI_Machine.ts @@ -148,9 +148,13 @@ export function renderMachines( void load(); }; + let ticket = 0; // 늦게 온 옛 응답이 새 목록을 덮지 않게(829-1) const load = async (): Promise => { + const mine = ++ticket; try { - data = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, pick.state); + const got = await fetchRows(file, page, size, q, false, pick.sub, pick.detail, pick.state); + if (mine !== ticket) return; + data = got; saveView(pick.group, { page: data.page }); paint(); void fillHourly(data.rows.map((r) => String(r["키"]))); @@ -172,6 +176,7 @@ export function renderMachines( const off = onDraftChange(paint); void load(); return () => { + ticket++; fit.stop(); off(); }; diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 78eae7c43..05ccf90c7 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -216,7 +216,9 @@ function buildPage(): HTMLElement { search.addEventListener("input", () => { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { - query = search.value.trim(); + const next = search.value.trim(); + if (next === query) return; // 같은 글이면 목록을 다시 받지 않음(829-1) + query = next; if (current) openFile(current); }, 300); }); diff --git a/M01_MasterData/M01_MasterData_UI_Pick.ts b/M01_MasterData/M01_MasterData_UI_Pick.ts index e5bdc8d38..68dcd53c7 100644 --- a/M01_MasterData/M01_MasterData_UI_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Pick.ts @@ -96,10 +96,13 @@ export function openPickModal(opt: PickOptions): void { ); }; let timer: number | undefined; + let seq = 0; // 늦게 온 옛 응답은 버림(829-1) const run = async (fallback = false): Promise => { + const mine = ++seq; try { const q = search.value.trim(); const r = await fetchPick(opt.kind, q); + if (mine !== seq) return; if (!r.total && fallback && q.includes(" ")) { search.value = q.split(/\s+/)[0]; return run(); diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 89ec75704..5a9fed769 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -111,6 +111,7 @@ export function renderRows( let scrolled = false; let data: RowsPage | null = null; let unlinked = false; + let ticket = 0; // 늦게 온 옛 응답이 새 목록을 덮지 않게(829-1) const paint = (): void => { if (!data) return; @@ -270,8 +271,11 @@ export function renderRows( }; const load = async (): Promise => { + const mine = ++ticket; try { - data = await fetchRows(file, page, size, q, unlinked, sub, detail, state); + const got = await fetchRows(file, page, size, q, unlinked, sub, detail, state); + if (mine !== ticket) return; + data = got; for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name); saveView(pick.group, { page: data.page }); paint(); @@ -293,6 +297,7 @@ export function renderRows( const off = onDraftChange(paint); void load(); return () => { + ticket++; fit.stop(); off(); }; diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts index 335f2aac7..07a45a7f1 100644 --- a/M01_MasterData/M01_MasterData_UI_Tables.ts +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -49,10 +49,12 @@ export function renderTables( let usage = pick.view?.usage ?? ""; let size = guessRows(host, FILTER_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음 let at = 0; // 목록을 보고 있는 쪽(0 = 표 하나를 보는 중) + let gen = 0; // 늦게 온 옛 응답이 새 목록을 덮지 않게(829-1) const list = (page = pick.view?.page ?? 1): void => { stop(); stop = (): void => {}; at = page; + const mine = ++gen; void showList( host, pick, @@ -70,6 +72,7 @@ export function renderTables( saveView(pick.group, { usage, page: 1 }); list(1); }, + () => mine !== gen, ).then(() => fit.ask()); }; const fit = watchFit( @@ -85,6 +88,7 @@ export function renderTables( list(); const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host)); return () => { + gen++; fit.stop(); stop(); off(); @@ -109,10 +113,12 @@ async function showList( goPage: (page: number) => void, open: (file: string, key: string) => void, onUsageChange: (usage: string) => void, + stale: () => boolean, ): Promise { let got: { total: number; tables: TableHead[] }; try { got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, size, usage); + if (stale()) return; } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); return; diff --git a/resources/tester/test_709_pager_fit.py b/resources/tester/test_709_pager_fit.py index acf39b349..114f8f0bc 100644 --- a/resources/tester/test_709_pager_fit.py +++ b/resources/tester/test_709_pager_fit.py @@ -75,3 +75,13 @@ def test_옛_이름은_새_부품(): assert "createButton" not in cells parts = (ROOT / "ui_template" / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8") assert 'file: "ui_template_pager.ts"' in parts and 'file: "ui_template_divider.ts"' in parts + + +def test_829_키보드_높이만_바뀐_다시_재기_거름(): + # 글자 넣는 중 · 터치 · 폭 그대로 = 거름(true) · 폭이 바뀌었거나 터치 아님 · 글자 안 넣는 중이면 다시 잼(false) + got = _run( + "console.log(JSON.stringify([" + "holdForKeyboard(true,true,true), holdForKeyboard(true,true,false), " + "holdForKeyboard(true,false,true), holdForKeyboard(false,true,true)]));" + ) + assert got == [True, False, False, False] diff --git a/ui_template/ui_template_pager.ts b/ui_template/ui_template_pager.ts index a4ac7a58d..116069ff2 100644 --- a/ui_template/ui_template_pager.ts +++ b/ui_template/ui_template_pager.ts @@ -37,8 +37,22 @@ export function fitRows(room: number, rowH = ROW_H, headH = ROW_H): number { export const pageKeepFirst = (page: number, oldSize: number, newSize: number): number => Math.floor(((Math.max(1, page) - 1) * oldSize) / Math.max(1, newSize)) + 1; +/** 가상 키보드 흉내 가름(829-1) — 글자를 넣는 중 · 터치 기기 · 폭 그대로면 높이만 바뀐 것 = 줄 수를 다시 재지 않음(키보드가 뜨고 닫을 때마다 목록이 다시 그려지는 것 막음). */ +export const holdForKeyboard = (typing: boolean, touch: boolean, widthSame: boolean): boolean => + typing && touch && widthSame; + /* ── 아래부터 화면 재기(순수 셈 아님) ── */ +const TEXT_TYPES = ["text", "search", "email", "number", "password", "tel", "url", "date"]; +const typingNow = (): boolean => { + const a = document.activeElement; + if (a instanceof HTMLTextAreaElement) return true; + if (a instanceof HTMLInputElement) return TEXT_TYPES.includes(a.type); + return a instanceof HTMLElement && a.isContentEditable; +}; +const touchNow = (): boolean => + window.matchMedia("(pointer: coarse)").matches || document.documentElement.dataset.touch === "on"; + export interface FitOptions { /** 화면 위치 대신 쓸 높이 한도(px) — 모달처럼 위치가 줄 수에 따라 움직이는 곳 */ cap?: () => number; @@ -75,9 +89,11 @@ export function watchFit( let frame = 0; let seen = false; let late = 0; + let lastW = window.innerWidth; const stop = (): void => { window.clearTimeout(late); window.removeEventListener("resize", ask); + document.removeEventListener("focusout", ask); watcher?.disconnect(); if (frame) cancelAnimationFrame(frame); }; @@ -87,9 +103,13 @@ export function watchFit( if (root.isConnected) seen = true; else if (seen) return stop(); } + const widthSame = window.innerWidth === lastW; + lastW = window.innerWidth; + if (holdForKeyboard(typingNow(), touchNow(), widthSame)) return; // 키보드가 닫히면(focusout) 그때 잼 const grid = getGrid(); if (grid) watcher?.observe(grid); // 글꼴이 늦게 실려 줄 높이가 바뀌어도 다시 잼 const n = grid ? measureRows(grid, opt) : null; + if (n !== null) root.dataset.fitRows = String(n); // 같은 칸에 다시 그릴 때(찾기) 첫 줄 수 어림으로 씀 if (n !== null && n !== last) { last = n; onRows(n); @@ -102,6 +122,7 @@ export function watchFit( late = window.setTimeout(check, 400); } window.addEventListener("resize", ask); + document.addEventListener("focusout", ask); const watcher = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(ask); watcher?.observe(root); // 숨었다 다시 보일 때 · 위 칸이 늘고 줄 때 ask(); @@ -110,6 +131,8 @@ export function watchFit( /** 첫 그림 전에 쓸 줄 수 어림 — `above` = 표 칸 위에 올 줄(px). */ export function guessRows(host: HTMLElement, above = 0, cap?: () => number): number { + const known = Number(host.dataset.fitRows); // 바로 앞에 잰 값 — 어림과 어긋나 두 번 받아 오는 것 막음 + if (known >= MIN_ROWS) return Math.min(MAX_ROWS, known); const room = cap ? cap() : window.innerHeight - host.getBoundingClientRect().top - above; return fitRows(room - PAGER_ROOM); }