fix(829-1): 모바일 찾기 칸 되풀이 다시 그리기 — 늦은 옛 응답 덮어쓰기 막음 · 한글 조합 중 검색 보류 · 키보드로 높이만 바뀐 뒤 줄 수 다시 재기 거름
- 원인 셋: ① 목록 불러오기 응답 순서 가드 없음(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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -93,11 +93,14 @@ function openTablePick(onPick: (table: TableHead) => void): void {
|
||||
});
|
||||
|
||||
let timer: number | undefined;
|
||||
let seq = 0; // 늦게 온 옛 응답은 버림(829-1)
|
||||
const run = async (): Promise<void> => {
|
||||
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
|
||||
|
||||
@@ -148,9 +148,13 @@ export function renderMachines(
|
||||
void load();
|
||||
};
|
||||
|
||||
let ticket = 0; // 늦게 온 옛 응답이 새 목록을 덮지 않게(829-1)
|
||||
const load = async (): Promise<void> => {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<void> => {
|
||||
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();
|
||||
|
||||
@@ -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<void> => {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user