Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1
This commit is contained in:
@@ -29,15 +29,18 @@ export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
/** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */
|
||||
let timerView: Window = window;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (deps.editing()) return;
|
||||
const fitted = deps.fitElevation();
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
timerView.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
timerView = body.ownerDocument.defaultView ?? window;
|
||||
scrollSettleTimer = timerView.setTimeout(() => {
|
||||
if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
deps.draw();
|
||||
|
||||
@@ -37,16 +37,25 @@ const BASE_FRAME_MS = 1000 / 60;
|
||||
* 쓰면 한 번에 목표까지 튄다. */
|
||||
const MAX_FRAME_MS = 64;
|
||||
|
||||
/** 스크롤러가 지금 놓인 창 — 종단을 새 창으로 빼면 그 창이다(230-1).
|
||||
* 메인 창 전역 `requestAnimationFrame` 을 쓰면, 새 창을 최대화해 메인 창이 가려질 때
|
||||
* 브라우저가 메인 창 프레임을 멈춰 휠 이동이 한 걸음도 안 나갔다. */
|
||||
function viewOf(scroller: HTMLElement): Window {
|
||||
return scroller.ownerDocument.defaultView ?? window;
|
||||
}
|
||||
|
||||
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
let target = -1;
|
||||
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
|
||||
let applied = -1;
|
||||
let frame = 0;
|
||||
/** `frame` 을 받아 둔 창 — 걷을 때도 같은 창에서 걷는다. */
|
||||
let frameView: Window = window;
|
||||
/** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */
|
||||
let lastFrameAt = 0;
|
||||
|
||||
function stop(): void {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
if (frame) frameView.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
target = -1;
|
||||
applied = -1;
|
||||
@@ -72,7 +81,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS);
|
||||
scroller.scrollLeft = scroller.scrollLeft + rest * ratio;
|
||||
applied = scroller.scrollLeft;
|
||||
frame = requestAnimationFrame(step);
|
||||
frame = frameView.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
scroller.addEventListener(
|
||||
@@ -89,7 +98,8 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
target = next;
|
||||
if (!frame) {
|
||||
applied = scroller.scrollLeft;
|
||||
frame = requestAnimationFrame(step);
|
||||
frameView = viewOf(scroller);
|
||||
frame = frameView.requestAnimationFrame(step);
|
||||
}
|
||||
event.preventDefault();
|
||||
},
|
||||
@@ -128,7 +138,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
};
|
||||
scroller.addEventListener("click", swallow, { capture: true, once: true });
|
||||
// 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다.
|
||||
requestAnimationFrame(() =>
|
||||
viewOf(scroller).requestAnimationFrame(() =>
|
||||
scroller.removeEventListener("click", swallow, { capture: true }),
|
||||
);
|
||||
}
|
||||
@@ -164,7 +174,7 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
// 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가
|
||||
// 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다.
|
||||
scroller.style.userSelect = "none";
|
||||
window.getSelection()?.removeAllRanges();
|
||||
viewOf(scroller).getSelection()?.removeAllRanges();
|
||||
}
|
||||
scroller.scrollLeft = dragLeft - dx;
|
||||
scroller.scrollTop = dragTop - dy;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/* 종단 새 창 + 최대화 휠(계획서 230-1) 단위검증 헬퍼 — 휠 이동(`B05_Profile_UI_Profile_Wheel.ts`) ·
|
||||
* 스크롤 뒤 다시 그리기 시계(`design_profile_viewport.ts`)를 그 자리에서 트랜스파일해 돌리고 JSON 으로 냄.
|
||||
* 메인 창은 「가려짐」 — 화면 틀 · 시계가 돌지 않음(새 창 최대화가 메인 창을 덮은 꼴). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
function load(file, mods = {}) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (p) => mods[p] ?? require(p));
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
// ---- 가짜 창 — frozen 이면 화면 틀 · 시계가 안 돎 ----
|
||||
function fakeWindow(name, frozen) {
|
||||
const win = { name, frozen, now: 0, timers: [], seq: 0, frames: 0 };
|
||||
win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq);
|
||||
win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id));
|
||||
win.requestAnimationFrame = (fn) => win.setTimeout(() => (win.frames++, fn(win.now)), 16);
|
||||
win.cancelAnimationFrame = win.clearTimeout;
|
||||
win.getSelection = () => ({ removeAllRanges: () => (win.cleared = true) });
|
||||
win.tick = (ms) => {
|
||||
if (win.frozen) return;
|
||||
const end = win.now + ms;
|
||||
for (;;) {
|
||||
const next = win.timers.filter((t) => t.at <= end).sort((a, b) => a.at - b.at)[0];
|
||||
if (!next) break;
|
||||
win.now = next.at;
|
||||
win.clearTimeout(next.id);
|
||||
next.fn();
|
||||
}
|
||||
win.now = end;
|
||||
};
|
||||
return win;
|
||||
}
|
||||
|
||||
function fakeScroller(view, { width, scrollWidth }) {
|
||||
const calls = {};
|
||||
let left = 0;
|
||||
const el = {
|
||||
ownerDocument: { defaultView: view },
|
||||
clientWidth: width,
|
||||
clientHeight: 400,
|
||||
scrollWidth,
|
||||
scrollTop: 0,
|
||||
style: {},
|
||||
addEventListener: (type, fn) => (calls[type] ??= []).push(fn),
|
||||
removeEventListener: () => {},
|
||||
hasPointerCapture: () => false,
|
||||
setPointerCapture: () => {},
|
||||
releasePointerCapture: () => {},
|
||||
fire: (type, ev = {}) => {
|
||||
const e = { prevented: false, preventDefault: () => (e.prevented = true), ...ev };
|
||||
(calls[type] ?? []).forEach((fn) => fn(e));
|
||||
return e;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(el, "scrollLeft", {
|
||||
get: () => left,
|
||||
set: (v) => (left = Math.min(Math.max(0, v), el.scrollWidth - el.clientWidth)),
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
const main = fakeWindow("main", true);
|
||||
globalThis.window = main;
|
||||
globalThis.requestAnimationFrame = main.requestAnimationFrame; // 옛 코드가 쓰던 전역 = 메인 창
|
||||
globalThis.cancelAnimationFrame = main.cancelAnimationFrame;
|
||||
const roCallbacks = [];
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(fn) {
|
||||
roCallbacks.push(fn);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"));
|
||||
const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), {
|
||||
"../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod,
|
||||
"@util/common_util_chart_ywindow": { needsFullRedraw: () => true },
|
||||
});
|
||||
|
||||
function wheelDown(scroller, view, notches) {
|
||||
const prevented = [];
|
||||
for (let i = 0; i < notches; i++)
|
||||
prevented.push(scroller.fire("wheel", { deltaY: 120 }).prevented);
|
||||
view.tick(1000);
|
||||
return { left: Math.round(scroller.scrollLeft), prevented };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
// ① 새 창 보통 크기(1400) — 메인 창은 가려짐
|
||||
const popup = fakeWindow("popup", false);
|
||||
const s1 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 });
|
||||
wheelMod.attachWheelHorizontalScroll(s1);
|
||||
out.popupNormal = {
|
||||
...wheelDown(s1, popup, 3),
|
||||
popupFrames: popup.frames,
|
||||
mainTimers: main.timers.length,
|
||||
};
|
||||
|
||||
// ② 새 창 최대화(1400 → 1920) 뒤 휠 — 크기 바뀜 듣개가 돈 뒤에도 이동
|
||||
const popup2 = fakeWindow("popup2", false);
|
||||
const s2 = fakeScroller(popup2, { width: 1400, scrollWidth: 6000 });
|
||||
let draws = 0;
|
||||
let lightSyncs = 0;
|
||||
const last = { width: 1400, height: 400 };
|
||||
const unbind = viewport.bindProfileViewport({
|
||||
body: s2,
|
||||
editing: () => false,
|
||||
fitElevation: () => ({}),
|
||||
draw: () => draws++,
|
||||
lastSize: () => last,
|
||||
scheduleLightSync: () => lightSyncs++,
|
||||
scheduleDraw: () => {},
|
||||
});
|
||||
s2.clientWidth = 1920;
|
||||
s2.clientHeight = 1000;
|
||||
roCallbacks.at(-1)();
|
||||
const before = main.timers.length;
|
||||
const moved = wheelDown(s2, popup2, 2);
|
||||
s2.fire("scroll");
|
||||
popup2.tick(200);
|
||||
out.popupMax = {
|
||||
...moved,
|
||||
limit: s2.scrollWidth - s2.clientWidth,
|
||||
lightSyncs,
|
||||
draws,
|
||||
mainTimersAdded: main.timers.length - before,
|
||||
};
|
||||
unbind();
|
||||
|
||||
// ③ 끝까지 굴림 — 한도에서 멈추고 더는 막지 않음(기본 스크롤로 넘김)
|
||||
const s3 = fakeScroller(popup, { width: 1920, scrollWidth: 2200 });
|
||||
wheelMod.attachWheelHorizontalScroll(s3);
|
||||
const atEnd = wheelDown(s3, popup, 5);
|
||||
out.popupEnd = { ...atEnd, after: s3.fire("wheel", { deltaY: 120 }).prevented };
|
||||
|
||||
// ④ 원래 창(가려지지 않음) — 자기 창 화면 틀로 그대로 이동
|
||||
main.frozen = false;
|
||||
const s4 = fakeScroller(main, { width: 1400, scrollWidth: 6000 });
|
||||
wheelMod.attachWheelHorizontalScroll(s4);
|
||||
out.mainWindow = wheelDown(s4, main, 3);
|
||||
|
||||
// ⑤ 끌기 — 글자 선택은 스크롤러가 놓인 창에서 풂
|
||||
const s5 = fakeScroller(popup, { width: 1400, scrollWidth: 6000 });
|
||||
wheelMod.attachWheelHorizontalScroll(s5);
|
||||
s5.fire("pointerdown", { button: 0, pointerId: 1, clientX: 500, clientY: 100, target: null });
|
||||
s5.fire("pointermove", { pointerId: 1, clientX: 400, clientY: 100 });
|
||||
out.drag = { left: s5.scrollLeft, popupCleared: !!popup.cleared, mainCleared: !!main.cleared };
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""종단 새 창 + 최대화 휠(계획서 230-1) 회귀검증.
|
||||
|
||||
까닭 — 휠 이동 · 스크롤 뒤 다시 그리기가 메인 창 전역 화면 틀 · 시계를 썼음. 새 창을 최대화하면
|
||||
메인 창이 가려져 브라우저가 그 틀을 멈춤 → 휠은 막히고(preventDefault) 이동은 0.
|
||||
고침 — 스크롤러가 놓인 창(`ownerDocument.defaultView`)의 틀 · 시계를 씀.
|
||||
|
||||
`helper_230_1_popout_wheel.cjs` 가 메인 창을 「가려짐」(틀 · 시계 멈춤)으로 두고 판정함.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_230_1_popout_wheel.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_새_창_보통_휠_이동_메인_틀_안_씀(r):
|
||||
p = r["popupNormal"]
|
||||
assert p["left"] == 360 # 세 칸 × 120
|
||||
assert p["prevented"] == [True, True, True]
|
||||
assert p["popupFrames"] > 0 and p["mainTimers"] == 0
|
||||
|
||||
|
||||
def test_새_창_최대화_뒤_휠_이동(r):
|
||||
p = r["popupMax"]
|
||||
assert p["limit"] == 4080 # 6000 − 1920 — 최대화해도 스크롤할 거리 남음
|
||||
assert p["lightSyncs"] == 1 # 크기 바뀜 듣개가 돎
|
||||
assert p["left"] == 240 # 두 칸 × 120
|
||||
assert p["draws"] == 1 # 스크롤 뒤 다시 그리기도 새 창 시계로
|
||||
assert p["mainTimersAdded"] == 0
|
||||
|
||||
|
||||
def test_끝에서_멈추고_기본_스크롤로_넘김(r):
|
||||
p = r["popupEnd"]
|
||||
assert p["left"] == 280 # 한도 2200 − 1920
|
||||
assert p["prevented"][-1] is False and p["after"] is False
|
||||
|
||||
|
||||
def test_원래_창_휠_그대로(r):
|
||||
assert r["mainWindow"] == {"left": 360, "prevented": [True, True, True]}
|
||||
|
||||
|
||||
def test_끌기_글자_선택은_새_창에서_풂(r):
|
||||
d = r["drag"]
|
||||
assert d["left"] == 100
|
||||
assert d["popupCleared"] is True and d["mainCleared"] is False
|
||||
Reference in New Issue
Block a user