Merge branch 'sub_laptop_1' into main_laptop_1
This commit is contained in:
+267
-31
@@ -11,6 +11,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_l
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo, isAuthenticated } from "./router";
|
||||
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
|
||||
|
||||
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
|
||||
|
||||
@@ -65,38 +66,12 @@ function buildHeader(): HTMLElement {
|
||||
const header = document.createElement("header");
|
||||
header.className = "app-header";
|
||||
|
||||
// 로고 (클릭 시 홈)
|
||||
const brand = document.createElement("button");
|
||||
brand.className = "app-brand";
|
||||
brand.type = "button";
|
||||
const brandIcon = document.createElement("img");
|
||||
brandIcon.className = "app-brand__icon";
|
||||
brandIcon.src = BRAND_ICON_URL;
|
||||
brandIcon.alt = "";
|
||||
const brandName = document.createElement("span");
|
||||
brandName.className = "app-brand__name";
|
||||
brandName.textContent = L("App_BrandName");
|
||||
brand.append(brandIcon, brandName);
|
||||
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
|
||||
const brand = buildBrand();
|
||||
|
||||
// 네비게이션 링크
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "app-nav";
|
||||
const navItems: [keyof typeof ui_locales, string][] = [
|
||||
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
|
||||
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
|
||||
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
|
||||
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
|
||||
["App_Nav_Support", ROUTES.A08_SUPPORT],
|
||||
];
|
||||
for (const [labelKey, route] of navItems) {
|
||||
const link = createButton({
|
||||
label: L(labelKey),
|
||||
variant: "pill",
|
||||
onClick: () => navigateTo(route as never),
|
||||
});
|
||||
nav.append(link);
|
||||
}
|
||||
nav.append(...buildNavButtons());
|
||||
|
||||
// 우측 액션 (언어/테마/로그인)
|
||||
const actions = document.createElement("div");
|
||||
@@ -120,10 +95,157 @@ function buildHeader(): HTMLElement {
|
||||
actions.append(authSlot);
|
||||
void fillAuthActions(authSlot);
|
||||
|
||||
header.append(brand, nav, actions);
|
||||
header.append(brand, nav, actions, ...buildDrawer(header));
|
||||
return header;
|
||||
}
|
||||
|
||||
/** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */
|
||||
function buildBrand(): HTMLButtonElement {
|
||||
const brand = document.createElement("button");
|
||||
brand.className = "app-brand";
|
||||
brand.type = "button";
|
||||
const brandIcon = document.createElement("img");
|
||||
brandIcon.className = "app-brand__icon";
|
||||
brandIcon.src = BRAND_ICON_URL;
|
||||
brandIcon.alt = "";
|
||||
const brandName = document.createElement("span");
|
||||
brandName.className = "app-brand__name";
|
||||
brandName.textContent = L("App_BrandName");
|
||||
brand.append(brandIcon, brandName);
|
||||
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
|
||||
return brand;
|
||||
}
|
||||
|
||||
/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠.
|
||||
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
|
||||
let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null;
|
||||
|
||||
export function setDrawerSteps(node: HTMLElement | null): void {
|
||||
drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: [keyof typeof ui_locales, string][] = [
|
||||
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
|
||||
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
|
||||
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
|
||||
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
|
||||
["App_Nav_Support", ROUTES.A08_SUPPORT],
|
||||
];
|
||||
|
||||
function buildNavButtons(): HTMLElement[] {
|
||||
return NAV_ITEMS.map(([labelKey, route]) =>
|
||||
createButton({
|
||||
label: L(labelKey),
|
||||
variant: "pill",
|
||||
onClick: () => navigateTo(route as never),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* ☰ 서랍(PLAN_기기대응 2-2 · 7-1) — 공개 쪽은 폭 900 이하 · 터치 모드에서 · 로그인 뒤 쪽(B · M)은 늘 보임.
|
||||
* 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치.
|
||||
* -------------------------------------------------------------------------- */
|
||||
function buildDrawer(header: HTMLElement): HTMLElement[] {
|
||||
const burger = document.createElement("button");
|
||||
burger.type = "button";
|
||||
burger.className = "app-burger";
|
||||
burger.textContent = "☰";
|
||||
burger.setAttribute("aria-label", L("App_Menu"));
|
||||
burger.setAttribute("aria-expanded", "false");
|
||||
const drawer = document.createElement("div");
|
||||
drawer.className = "app-drawer";
|
||||
drawer.setAttribute("role", "dialog");
|
||||
drawer.setAttribute("aria-label", L("App_Menu"));
|
||||
const scrim = document.createElement("div");
|
||||
scrim.className = "app-drawer__scrim";
|
||||
const steps = document.createElement("div");
|
||||
steps.className = "app-drawer__group app-drawer__steps";
|
||||
const setOpen = (open: boolean): void => {
|
||||
header.classList.toggle("is-drawer-open", open);
|
||||
burger.setAttribute("aria-expanded", String(open));
|
||||
if (!drawerSteps) return;
|
||||
if (open) steps.append(drawerSteps.node);
|
||||
else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node);
|
||||
};
|
||||
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
|
||||
scrim.addEventListener("click", () => setOpen(false));
|
||||
header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘
|
||||
// 로고 · 진행 단계 · 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠)
|
||||
drawer.addEventListener("click", (e) => {
|
||||
const hit = (e.target as Element).closest("button");
|
||||
if (hit?.closest(".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth"))
|
||||
setOpen(false);
|
||||
});
|
||||
|
||||
// 묶음 차례 = 로고 · 홈 ─ 진행 단계(프로젝트 안) ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드
|
||||
const brandRow = document.createElement("div");
|
||||
brandRow.className = "app-drawer__group app-drawer__brand";
|
||||
brandRow.append(buildBrand());
|
||||
const navList = document.createElement("nav");
|
||||
navList.className = "app-drawer__group app-drawer__nav";
|
||||
navList.append(...buildNavButtons());
|
||||
const prefs = document.createElement("div");
|
||||
prefs.className = "app-drawer__row";
|
||||
prefs.append(
|
||||
createButton({
|
||||
label: currentLanguageIndex === 0 ? "EN" : "한",
|
||||
variant: "pill",
|
||||
onClick: onApp_Lang_Toggle_Click,
|
||||
}),
|
||||
createButton({
|
||||
label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾",
|
||||
variant: "pill",
|
||||
onClick: onApp_Theme_Toggle_Click,
|
||||
}),
|
||||
);
|
||||
const auth = document.createElement("div");
|
||||
auth.className = "app-drawer__row app-drawer__auth";
|
||||
void fillAuthActions(auth);
|
||||
const settings = document.createElement("div");
|
||||
settings.className = "app-drawer__group";
|
||||
settings.append(prefs, auth, buildTouchModeSwitch());
|
||||
drawer.append(brandRow, steps, navList, settings);
|
||||
return [burger, scrim, drawer];
|
||||
}
|
||||
|
||||
function buildTouchModeSwitch(): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "app-drawer__touch";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = L("App_TouchMode");
|
||||
const group = document.createElement("div");
|
||||
group.className = "app-drawer__row";
|
||||
group.setAttribute("role", "radiogroup");
|
||||
group.setAttribute("aria-label", L("App_TouchMode"));
|
||||
const modes: [TouchMode, keyof typeof ui_locales][] = [
|
||||
["auto", "App_TouchMode_Auto"],
|
||||
["on", "App_TouchMode_On"],
|
||||
["off", "App_TouchMode_Off"],
|
||||
];
|
||||
const buttons = modes.map(([mode, key]) => {
|
||||
const b = createButton({
|
||||
label: L(key),
|
||||
variant: "pill",
|
||||
onClick: () => {
|
||||
setTouchMode(mode);
|
||||
mark();
|
||||
},
|
||||
});
|
||||
b.setAttribute("role", "radio");
|
||||
b.dataset.touchMode = mode;
|
||||
return b;
|
||||
});
|
||||
const mark = (): void => {
|
||||
const now = readTouchMode();
|
||||
for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now));
|
||||
};
|
||||
mark();
|
||||
group.append(...buttons);
|
||||
box.append(label, group);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
|
||||
async function fillAuthActions(slot: HTMLElement): Promise<void> {
|
||||
try {
|
||||
@@ -188,6 +310,7 @@ function buildFooter(): HTMLElement {
|
||||
* -------------------------------------------------------------------------- */
|
||||
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
|
||||
appRoot.innerHTML = "";
|
||||
drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김
|
||||
const header = buildHeader();
|
||||
const outlet = document.createElement("main");
|
||||
outlet.className = "app-outlet";
|
||||
@@ -259,10 +382,10 @@ const SHELL_CSS = `
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.app-outlet {
|
||||
min-height: calc(100vh - 64px - 56px);
|
||||
min-height: calc(100dvh - var(--app-header-h) - 56px);
|
||||
}
|
||||
.app-outlet--without-footer {
|
||||
min-height: calc(100vh - 64px);
|
||||
min-height: calc(100dvh - var(--app-header-h));
|
||||
}
|
||||
.app-footer {
|
||||
height: 56px;
|
||||
@@ -285,7 +408,120 @@ const SHELL_CSS = `
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.app-nav { display: none; }
|
||||
.app-actions { margin-left: auto; }
|
||||
}
|
||||
/* ☰ 서랍 — 공개 쪽은 폭 900 이하 · 터치 모드에서만 · 로그인 뒤 쪽(B · M)은 늘 */
|
||||
.app-burger,
|
||||
.app-drawer,
|
||||
.app-drawer__scrim {
|
||||
display: none;
|
||||
}
|
||||
.app-burger {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
|
||||
.app-header.is-drawer-open { z-index: var(--z-overlay); }
|
||||
.app-header.is-drawer-open .app-drawer__scrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--app-header-h) 0 0 0;
|
||||
background: rgba(38, 17, 74, 0.35);
|
||||
}
|
||||
.app-header.is-drawer-open .app-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
position: fixed;
|
||||
top: var(--app-header-h);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 100vw);
|
||||
padding: var(--spacing-16);
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface-raised);
|
||||
border-left: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.app-drawer__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
|
||||
.app-drawer__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.app-drawer__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.app-drawer__group + .app-drawer__group {
|
||||
padding-top: var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.app-drawer__steps:empty { display: none; }
|
||||
.app-drawer__touch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.app-drawer__touch [aria-checked="true"] {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
|
||||
html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); }
|
||||
html[data-size="compact"] .app-actions { display: none; }
|
||||
html[data-size="compact"] .app-burger { margin-left: auto; }
|
||||
/* 로그인 뒤 쪽(B · M) — 폭 · 입력과 상관없이 위 줄을 걷고 ☰ 만 화면 왼쪽 위 모서리에 띄움(7-1)
|
||||
본문이 화면 높이를 다 씀 · 서랍은 왼쪽에서 열림 · 위 줄 항목은 모두 서랍에 있음 */
|
||||
html.is-bm-route .app-header {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
z-index: calc(var(--z-dropdown) + 1);
|
||||
}
|
||||
html.is-bm-route .app-header > :is(.app-brand, .app-nav, .app-actions) {
|
||||
display: none;
|
||||
}
|
||||
/* ☰ 폭 36(7-4) · 높이는 손가락 44 · 마우스 36 */
|
||||
html.is-bm-route .app-burger {
|
||||
min-width: var(--app-burger-w);
|
||||
min-height: var(--app-burger-w);
|
||||
position: fixed;
|
||||
top: var(--spacing-8);
|
||||
left: var(--spacing-8);
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
|
||||
html.is-bm-route .app-header.is-drawer-open .app-drawer {
|
||||
left: 0;
|
||||
right: auto;
|
||||
border-left: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
html.is-bm-route .app-drawer__brand {
|
||||
min-height: 44px;
|
||||
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
|
||||
}
|
||||
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
|
||||
`;
|
||||
|
||||
export function injectShellStyles(): void {
|
||||
|
||||
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.b-scaffold-wf { height: calc(100vh - 64px - 56px); }
|
||||
.b-scaffold-wf { height: calc(100dvh - var(--app-header-h) - 56px); }
|
||||
|
||||
.b-pending {
|
||||
display: flex;
|
||||
|
||||
@@ -219,6 +219,38 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
|
||||
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
|
||||
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
|
||||
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
|
||||
const touches = new Map<number, { x: number; y: number }>();
|
||||
const touchPair = (): { x: number; y: number; dist: number } => {
|
||||
const [a, b] = [...touches.values()];
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
|
||||
};
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
if (event.pointerType === "touch")
|
||||
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
});
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
|
||||
const before = touches.size === 2 ? touchPair() : null;
|
||||
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (!before) return;
|
||||
const after = touchPair();
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const w = Math.max(rect.width, 1);
|
||||
const h = Math.max(rect.height, 1);
|
||||
tx += ((after.x - before.x) / w) * widthPx;
|
||||
ty += ((after.y - before.y) / h) * heightPx;
|
||||
moved = true;
|
||||
const cx = ((after.x - rect.left) / w) * widthPx;
|
||||
const cy = ((after.y - rect.top) / h) * heightPx;
|
||||
zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy);
|
||||
});
|
||||
const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId);
|
||||
svg.addEventListener("pointerup", liftTouch);
|
||||
svg.addEventListener("pointercancel", liftTouch);
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
({ scale, tx, ty } = home);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =============================================================================
|
||||
* device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6)
|
||||
* 모든 규칙은 `<html>` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜.
|
||||
* 표시는 `device_context.ts` 가 적음.
|
||||
* ========================================================================== */
|
||||
|
||||
/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */
|
||||
html[data-touch="on"]
|
||||
:is(
|
||||
.ui-btn,
|
||||
.ui-workflow-overlay__toggle,
|
||||
.ui-sidebar-row,
|
||||
.ui-workflow-layout__step,
|
||||
.ui-ctx-menu__item,
|
||||
select,
|
||||
input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"])
|
||||
) {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html[data-touch="on"] .ui-btn {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */
|
||||
html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
html[data-touch="on"] .ui-checkbox {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* 접힌 좌측 패널 손잡이 — 손가락 폭 · 화면 왼쪽 끝 안 */
|
||||
html[data-touch="on"]
|
||||
.ui-workflow-overlay__panel--title.is-collapsed
|
||||
> .ui-workflow-overlay__handle--side {
|
||||
width: 32px;
|
||||
right: -32px;
|
||||
}
|
||||
|
||||
/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창.
|
||||
공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */
|
||||
html[data-size="compact"] :is(.ui-modal, .ui-popup) {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
transform: none !important;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
html[data-size="compact"] .ui-confirm__panel {
|
||||
min-width: 0;
|
||||
max-width: calc(100vw - var(--spacing-32));
|
||||
}
|
||||
|
||||
/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */
|
||||
html:is([data-size="compact"], [data-size="medium"])
|
||||
.ui-data-table__table:not(:has(.is-frozen))
|
||||
tr
|
||||
> :first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html:is([data-size="compact"], [data-size="medium"])
|
||||
.ui-data-table__table:not(:has(.is-frozen))
|
||||
thead
|
||||
tr
|
||||
> :first-child {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* 터치 모드에서만 보이는 단추(자판 없는 기기용 · 4-3) — 마우스 화면엔 없음 */
|
||||
html:not([data-touch="on"]) .ui-touch-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 위 줄 높이(6-1) — 화면 높이 계산이 모두 이 값을 씀 · 로그인 뒤 쪽(B · M)은 위 줄을 걷어 0(7-1)
|
||||
☰ 폭(7-4) · 들여쓰기(7-2) = 왼쪽 위에 뜬 ☰(자리 8 + 폭) + 틈 8 · 그 자리에 걸치는 첫 줄이 이만큼 왼쪽을 비움 */
|
||||
:root {
|
||||
--app-header-h: 64px;
|
||||
--app-burger-w: 36px;
|
||||
--app-burger-inset: 0px;
|
||||
}
|
||||
|
||||
html.is-bm-route {
|
||||
--app-header-h: 0px;
|
||||
--app-burger-inset: calc(var(--spacing-8) * 2 + var(--app-burger-w));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/* =============================================================================
|
||||
* device_context.ts
|
||||
* 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `<html>` 에 지금 상태를 늘 적음.
|
||||
*
|
||||
* - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄
|
||||
* - data-pointer = 주 입력 coarse · fine(미디어 `pointer`)
|
||||
* - data-input = 마지막 누름의 pointerType(mouse · touch · pen)
|
||||
* - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장)
|
||||
* - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때)
|
||||
* - data-burger = on · off — 공개 쪽에서 ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) · 로그인 뒤 쪽(B · M)은 늘 ☰(7-1)
|
||||
* 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태).
|
||||
* 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수.
|
||||
* 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./device_context.css";
|
||||
|
||||
export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const;
|
||||
/** 위 메뉴가 숨는 폭(app_shell `@media (max-width: 900px)` 과 짝) — 이하면 ☰ 서랍 */
|
||||
export const BURGER_MAX = 900;
|
||||
export type SizeClass = "compact" | "medium" | "expanded" | "large";
|
||||
export type TouchMode = "auto" | "on" | "off";
|
||||
|
||||
export interface DeviceContext {
|
||||
size: SizeClass;
|
||||
pointer: "coarse" | "fine";
|
||||
input: string;
|
||||
touchMode: TouchMode;
|
||||
touch: boolean;
|
||||
burger: boolean;
|
||||
}
|
||||
|
||||
const TOUCH_MODE_KEY = "frd_touch_mode";
|
||||
|
||||
export function sizeClassOf(width: number): SizeClass {
|
||||
if (width < SIZE_BREAKS.medium) return "compact";
|
||||
if (width < SIZE_BREAKS.expanded) return "medium";
|
||||
if (width < SIZE_BREAKS.large) return "expanded";
|
||||
return "large";
|
||||
}
|
||||
|
||||
let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용
|
||||
|
||||
export function readTouchMode(): TouchMode {
|
||||
if (modeInMemory) return modeInMemory;
|
||||
try {
|
||||
const v = localStorage.getItem(TOUCH_MODE_KEY);
|
||||
return v === "on" || v === "off" ? v : "auto";
|
||||
} catch {
|
||||
return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
const coarseQuery = window.matchMedia("(pointer: coarse)");
|
||||
let current: DeviceContext | null = null;
|
||||
let lastInput = "";
|
||||
|
||||
function read(): DeviceContext {
|
||||
const pointer = coarseQuery.matches ? "coarse" : "fine";
|
||||
const touchMode = readTouchMode();
|
||||
const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse");
|
||||
return {
|
||||
size: sizeClassOf(window.innerWidth),
|
||||
pointer,
|
||||
input: lastInput || (pointer === "coarse" ? "touch" : "mouse"),
|
||||
touchMode,
|
||||
touch,
|
||||
burger: touch || window.innerWidth <= BURGER_MAX,
|
||||
};
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
const next = read();
|
||||
if (current && JSON.stringify(current) === JSON.stringify(next)) return;
|
||||
const html = document.documentElement;
|
||||
html.dataset.size = next.size;
|
||||
html.dataset.pointer = next.pointer;
|
||||
html.dataset.input = next.input;
|
||||
html.dataset.touchMode = next.touchMode;
|
||||
html.dataset.touch = next.touch ? "on" : "off";
|
||||
html.dataset.burger = next.burger ? "on" : "off";
|
||||
const first = !current;
|
||||
current = next;
|
||||
if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next }));
|
||||
}
|
||||
|
||||
export function deviceContext(): DeviceContext {
|
||||
return current ?? read();
|
||||
}
|
||||
|
||||
/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */
|
||||
export function setTouchMode(mode: TouchMode): void {
|
||||
modeInMemory = mode;
|
||||
try {
|
||||
if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY);
|
||||
else localStorage.setItem(TOUCH_MODE_KEY, mode);
|
||||
} catch {
|
||||
/* 저장 막힌 브라우저 — 이번 화면만 */
|
||||
}
|
||||
apply();
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
const html = document.documentElement;
|
||||
for (const [name, px] of Object.entries(SIZE_BREAKS))
|
||||
html.style.setProperty(`--bp-${name}`, `${px}px`);
|
||||
apply();
|
||||
coarseQuery.addEventListener("change", apply);
|
||||
window.addEventListener("resize", apply);
|
||||
window.visualViewport?.addEventListener("resize", apply);
|
||||
screen.orientation?.addEventListener("change", apply);
|
||||
window.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => {
|
||||
if (e.pointerType === lastInput) return;
|
||||
lastInput = e.pointerType;
|
||||
apply();
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -9,6 +9,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import "./random_uuid_polyfill";
|
||||
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
|
||||
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
|
||||
import "@ui/ui_template_theme.css";
|
||||
import { injectBaseStyles } from "@ui/ui_template_elements";
|
||||
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor, slotOf } from "./spreadsheet_editor";
|
||||
import { mountTouchKeys } from "./spreadsheet_touch_keys";
|
||||
import { toCalcValue } from "./spreadsheet_eval";
|
||||
import { attachExtras } from "./spreadsheet_extras";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
@@ -161,6 +162,7 @@ export function createSpreadsheet(
|
||||
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
|
||||
others.push(...extras.parts);
|
||||
const detachKeys = attachKeys(ctx, ed, extras.keys);
|
||||
mountTouchKeys(ctx, ed); // 자판 없는 기기 단추 줄(기기대응 4-3 · 터치 모드에서만 보임)
|
||||
const detachMouse = attachMouse(ctx, ed, extras.mouse);
|
||||
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
|
||||
let lateTabs: PartHandle | null = null;
|
||||
|
||||
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
|
||||
attrs: { type: "button", title: TEXT.close },
|
||||
});
|
||||
const dialog = el("div", {
|
||||
className: "ss-dialog",
|
||||
className: "ss-dialog ui-popup",
|
||||
attrs: { hidden: "" },
|
||||
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
|
||||
});
|
||||
|
||||
@@ -303,6 +303,14 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
||||
}
|
||||
|
||||
function focus(): void {
|
||||
// 손가락 — 고칠 때만 화면 자판(기기대응 4-2) · 칸 고름만으로는 안 뜸
|
||||
if (document.documentElement.dataset.touch === "on") {
|
||||
const mode = st ? "text" : "none";
|
||||
if (ta.inputMode !== mode) {
|
||||
ta.inputMode = mode;
|
||||
if (document.activeElement === ta) ta.blur();
|
||||
}
|
||||
}
|
||||
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
|
||||
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
|
||||
});
|
||||
const box = el("div", {
|
||||
className: "ss-steps__box",
|
||||
className: "ss-steps__box ui-popup",
|
||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||
});
|
||||
const shade = el("div", { className: "ss-steps", children: [box] });
|
||||
|
||||
@@ -263,3 +263,16 @@
|
||||
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 손가락(기기대응 4-2) — 채우기 손잡이를 키움 · 격자는 한 손가락 밀기만 브라우저에(두 손가락 = 격자 확대) */
|
||||
html[data-touch="on"] .aislo-grid__fill-handle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: -4px 0 0 -4px;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.aislo-grid {
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +553,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
|
||||
if (handleAt) {
|
||||
const screen = screenBoxOf(handleAt.r, handleAt.c);
|
||||
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
|
||||
// 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 4-2)
|
||||
const grip = document.documentElement.dataset.touch === "on" ? 20 : 5;
|
||||
if (
|
||||
Math.abs(x - (screen.x + screen.w)) <= grip &&
|
||||
Math.abs(y - (screen.y + screen.h)) <= grip
|
||||
) {
|
||||
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,11 +255,61 @@ export function attachMouse(
|
||||
};
|
||||
const onLeave = (): void => hooks.hover?.(null);
|
||||
|
||||
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
|
||||
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
|
||||
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
|
||||
let touchDrag = false;
|
||||
let lastTap = 0;
|
||||
const asMouse = (t: Touch, type: string): MouseEvent =>
|
||||
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
|
||||
const onTouchStart = (e: TouchEvent): void => {
|
||||
if (e.touches.length !== 1 || e.target === ed.ta) return;
|
||||
const t = e.touches[0];
|
||||
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
|
||||
const grab =
|
||||
hit &&
|
||||
(hit.kind === "fillHandle" ||
|
||||
hit.kind === "rowHead" ||
|
||||
hit.kind === "colHead" ||
|
||||
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
|
||||
if (!grab) return;
|
||||
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
|
||||
touchDrag = true;
|
||||
onDown(asMouse(t, "mousedown"));
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent): void => {
|
||||
if (!touchDrag) return;
|
||||
e.preventDefault();
|
||||
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
|
||||
};
|
||||
const onTouchEnd = (e: TouchEvent): void => {
|
||||
if (touchDrag) {
|
||||
touchDrag = false;
|
||||
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
|
||||
}
|
||||
if (e.touches.length || e.changedTouches.length !== 1) return;
|
||||
const t = e.changedTouches[0];
|
||||
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
|
||||
const now = Date.now();
|
||||
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
|
||||
lastTap = 0;
|
||||
ed.begin();
|
||||
return;
|
||||
}
|
||||
lastTap = now;
|
||||
};
|
||||
|
||||
root.addEventListener("mousedown", onDown);
|
||||
root.addEventListener("dblclick", onDbl);
|
||||
root.addEventListener("mousemove", onHover);
|
||||
root.addEventListener("mouseleave", onLeave);
|
||||
root.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||
root.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
root.addEventListener("touchend", onTouchEnd);
|
||||
return () => {
|
||||
root.removeEventListener("touchstart", onTouchStart);
|
||||
root.removeEventListener("touchmove", onTouchMove);
|
||||
root.removeEventListener("touchend", onTouchEnd);
|
||||
root.removeEventListener("mousemove", onHover);
|
||||
root.removeEventListener("mouseleave", onLeave);
|
||||
stopDrag?.();
|
||||
|
||||
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
|
||||
const caseBox = el("input", { attrs: { type: "checkbox" } });
|
||||
const list = el("div", { className: "ss-sort__keys" });
|
||||
const box = el("div", {
|
||||
className: "ss-sort__box",
|
||||
className: "ss-sort__box ui-popup",
|
||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||
});
|
||||
const shade = el("div", { className: "ss-sort", children: [box] });
|
||||
|
||||
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
|
||||
|
||||
const TEXT = {
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
|
||||
Redo: ["다시", "Redo"],
|
||||
FontName: ["글꼴", "Font"],
|
||||
FontSize: ["크기", "Size"],
|
||||
|
||||
@@ -291,3 +291,23 @@
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
|
||||
.ss-toolbar .ss-toolbar__more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-touch="on"] .ss-toolbar .ss-toolbar__more {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* 자판 없는 기기 단추 줄(기기대응 4-3 · spreadsheet_touch_keys.ts) — 터치 모드에서만 */
|
||||
.ss-touch-keys {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-touch="on"] .ss-touch-keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
||||
undoBtn.title = st("Undo");
|
||||
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
|
||||
redoBtn.title = st("Redo");
|
||||
// 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임)
|
||||
const moreBtn = createButton({
|
||||
label: "⋯",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const { box } = ctx.grid.editorSlot(r, c);
|
||||
const at = ctx.grid.root.getBoundingClientRect();
|
||||
const x = at.left + box.x + Math.min(box.w / 2, 24);
|
||||
const y = at.top + box.y + box.h / 2;
|
||||
const target = document.elementFromPoint(x, y) ?? ctx.grid.root;
|
||||
target.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button: 2,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
moreBtn.classList.add("ss-toolbar__more");
|
||||
moreBtn.title = st("CellMenu");
|
||||
|
||||
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
|
||||
const fontInput = el("input", {
|
||||
@@ -501,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
||||
});
|
||||
|
||||
root.append(
|
||||
group(undoBtn, redoBtn),
|
||||
group(undoBtn, redoBtn, moreBtn),
|
||||
group(fontInput, sizeInput),
|
||||
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
|
||||
group(textColor, textColorClear, fillColor, fillColorClear),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_touch_keys.ts
|
||||
* 자판 없는 기기용 단추 줄(기기대응 4-3) — 단축키로만 되던 일을 같은 키 길(`attachKeys`)로 보냄.
|
||||
* 입력줄(`ed.bar`) 끝에 붙고 터치 모드에서만 보임(spreadsheet_toolbar.css). 마우스 화면은 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import type { Editor } from "./spreadsheet_editor";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const KEYS: [label: string, title: string, init: KeyboardEventInit, edit: boolean][] = [
|
||||
["🔍", "찾기 · 바꾸기(Ctrl+F)", { key: "f", code: "KeyF", ctrlKey: true }, false],
|
||||
["⇉", "고른 칸 모두 같은 값(Ctrl+Enter)", { key: "Enter", ctrlKey: true }, true],
|
||||
["↵", "칸 안 줄바꿈(Alt+Enter)", { key: "Enter", altKey: true }, true],
|
||||
["✕", "고침 취소(Esc)", { key: "Escape" }, true],
|
||||
["📅", "오늘 날짜(Ctrl+;)", { key: ";", code: "Semicolon", ctrlKey: true }, true],
|
||||
["$", "참조 $ 돌리기(F4)", { key: "F4" }, true],
|
||||
];
|
||||
|
||||
export function mountTouchKeys(ctx: SpreadsheetContext, ed: Editor): void {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ss-touch-keys";
|
||||
for (const [label, title, init, edit] of KEYS) {
|
||||
if (edit && ctx.readOnly) continue;
|
||||
const b = createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
// 고치는 중이면 그 칸(입력줄 · 칸 편집기) — 아니면 칸 편집기로
|
||||
const target = document.activeElement === ed.formula ? ed.formula : ed.ta;
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true }),
|
||||
);
|
||||
},
|
||||
});
|
||||
b.title = title;
|
||||
b.setAttribute("aria-label", title);
|
||||
// 누를 때 초점을 뺏지 않음 — 고치던 칸이 그대로(손가락은 호환 mousedown 이 옴)
|
||||
b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
row.append(b);
|
||||
}
|
||||
ed.bar.append(row);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/* =============================================================================
|
||||
* touch_bridge.ts
|
||||
* 손가락 → 마우스 번역 한 곳(요구 193~195 · PLAN_기기대응 3-1) — 페이지 코드는 마우스 그대로.
|
||||
*
|
||||
* - 길게 누름(500ms · 10px 안) → 그 자리에 `contextmenu` · 뒤따르는 click 막음
|
||||
* (안드로이드처럼 브라우저가 먼저 내면 그것 하나만)
|
||||
* - 두 번 톡 → 브라우저가 `dblclick` 을 안 낼 때만 대신
|
||||
* - 두 손가락 벌림 → 가운데 자리에 Ctrl+휠(deltaY = −100·ln(배율) · 배율 10% 마다 한 번 · 트랙패드 핀치와 같은 꼴)
|
||||
* 받는 면이 touch-action 으로 브라우저 확대를 막은 곳에서만 끝까지 옴
|
||||
* - `[data-touch-own]` 요소(3D · 공용 SVG 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫
|
||||
* 마우스 · 펜 이벤트는 건드리지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
const LONG_MS = 500;
|
||||
const SLOP_PX = 10;
|
||||
const DOUBLE_MS = 300;
|
||||
const DOUBLE_PX = 25;
|
||||
/** 브라우저 dblclick 을 기다리는 틈 */
|
||||
const NATIVE_WAIT_MS = 350;
|
||||
/** 길게 누름 뒤 따라오는 click · 원래 contextmenu 를 막는 틈 */
|
||||
const AFTER_LONG_MS = 800;
|
||||
const PINCH_STEP = Math.log(1.1);
|
||||
|
||||
const synthetic = new WeakSet<Event>();
|
||||
|
||||
/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */
|
||||
export const isPinchWheel = (e: Event): boolean => synthetic.has(e);
|
||||
|
||||
let contactPx = 0;
|
||||
|
||||
/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */
|
||||
export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px);
|
||||
|
||||
/** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */
|
||||
export const wheelZoomsIn = (e: WheelEvent): boolean =>
|
||||
isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0;
|
||||
|
||||
const ownsTouch = (t: EventTarget | null): boolean =>
|
||||
t instanceof Element && !!t.closest("[data-touch-own]");
|
||||
const isEditable = (t: EventTarget | null): boolean =>
|
||||
t instanceof Element &&
|
||||
!!t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
|
||||
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const touches = new Map<number, Pt>();
|
||||
let press: (Pt & { id: number; target: Element; timer: ReturnType<typeof setTimeout> }) | null =
|
||||
null;
|
||||
let longAt = 0;
|
||||
let nativeMenuAt = 0;
|
||||
let nativeDblAt = 0;
|
||||
let lastTap: (Pt & { t: number }) | null = null;
|
||||
let pinch: { dist: number; acc: number } | null = null;
|
||||
|
||||
const at = (p: Pt, fallback: Element): Element => document.elementFromPoint(p.x, p.y) ?? fallback;
|
||||
|
||||
function fire(type: "contextmenu" | "dblclick", target: Element, p: Pt): void {
|
||||
const ev = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
clientX: p.x,
|
||||
clientY: p.y,
|
||||
screenX: p.x,
|
||||
screenY: p.y,
|
||||
button: type === "contextmenu" ? 2 : 0,
|
||||
detail: type === "dblclick" ? 2 : 1,
|
||||
});
|
||||
synthetic.add(ev);
|
||||
target.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
function fireWheel(target: Element, p: Pt, deltaY: number): void {
|
||||
const ev = new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
clientX: p.x,
|
||||
clientY: p.y,
|
||||
ctrlKey: true,
|
||||
deltaY,
|
||||
deltaMode: 0,
|
||||
});
|
||||
synthetic.add(ev);
|
||||
target.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
function cancelPress(): void {
|
||||
if (press) clearTimeout(press.timer);
|
||||
press = null;
|
||||
}
|
||||
|
||||
const pair = (): { mid: Pt; dist: number } => {
|
||||
const [a, b] = [...touches.values()];
|
||||
return {
|
||||
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
||||
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
||||
};
|
||||
};
|
||||
|
||||
/** 그림 면(SVG · 캔버스)을 처음 만지면 한 번 — 「길게 누르면 메뉴」(3-5 · 이 탭에서 한 번) */
|
||||
const HINT_KEY = "frd_touch_hint_shown";
|
||||
function hintOnce(target: Element): void {
|
||||
if (!target.closest("svg, canvas")) return;
|
||||
try {
|
||||
if (sessionStorage.getItem(HINT_KEY)) return;
|
||||
sessionStorage.setItem(HINT_KEY, "1");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void Promise.all([import("@ui/ui_template_elements"), import("@ui/ui_template_locale")]).then(
|
||||
([ui, loc]) => ui.showToast(loc.t("App_Touch_LongPressHint"), "info", 4000),
|
||||
);
|
||||
}
|
||||
|
||||
function onDown(e: PointerEvent): void {
|
||||
contactPx = e.pointerType === "touch" ? Math.max(24, e.width / 2, e.height / 2) : 0;
|
||||
if (e.pointerType !== "touch") return;
|
||||
const p = { x: e.clientX, y: e.clientY };
|
||||
touches.set(e.pointerId, p);
|
||||
const target = e.target as Element;
|
||||
hintOnce(target);
|
||||
if (touches.size === 1) {
|
||||
if (ownsTouch(target) || isEditable(target)) return;
|
||||
press = {
|
||||
...p,
|
||||
id: e.pointerId,
|
||||
target,
|
||||
timer: setTimeout(() => {
|
||||
const was = press;
|
||||
press = null;
|
||||
if (!was || Date.now() - nativeMenuAt < LONG_MS) return;
|
||||
longAt = Date.now();
|
||||
fire("contextmenu", at(was, was.target), was);
|
||||
}, LONG_MS),
|
||||
};
|
||||
return;
|
||||
}
|
||||
cancelPress();
|
||||
lastTap = null;
|
||||
pinch = touches.size === 2 && !ownsTouch(target) ? { dist: pair().dist, acc: 0 } : null;
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent): void {
|
||||
if (e.pointerType !== "touch" || !touches.has(e.pointerId)) return;
|
||||
const p = { x: e.clientX, y: e.clientY };
|
||||
touches.set(e.pointerId, p);
|
||||
if (press && press.id === e.pointerId && Math.hypot(p.x - press.x, p.y - press.y) > SLOP_PX) {
|
||||
cancelPress();
|
||||
lastTap = null;
|
||||
}
|
||||
if (!pinch || touches.size !== 2) return;
|
||||
const { mid, dist } = pair();
|
||||
if (pinch.dist > 0 && dist > 0) pinch.acc += Math.log(dist / pinch.dist);
|
||||
pinch.dist = dist;
|
||||
if (Math.abs(pinch.acc) < PINCH_STEP) return;
|
||||
fireWheel(at(mid, e.target as Element), mid, -100 * pinch.acc);
|
||||
pinch.acc = 0;
|
||||
}
|
||||
|
||||
function onUp(e: PointerEvent): void {
|
||||
if (e.pointerType !== "touch") return;
|
||||
touches.delete(e.pointerId);
|
||||
if (touches.size < 2) pinch = null;
|
||||
if (!press || press.id !== e.pointerId) return;
|
||||
const tap = press;
|
||||
cancelPress();
|
||||
const now = Date.now();
|
||||
if (
|
||||
lastTap &&
|
||||
now - lastTap.t < DOUBLE_MS &&
|
||||
Math.hypot(tap.x - lastTap.x, tap.y - lastTap.y) < DOUBLE_PX
|
||||
) {
|
||||
lastTap = null;
|
||||
setTimeout(() => {
|
||||
if (nativeDblAt < now) fire("dblclick", at(tap, tap.target), tap);
|
||||
}, NATIVE_WAIT_MS);
|
||||
return;
|
||||
}
|
||||
lastTap = { x: tap.x, y: tap.y, t: now };
|
||||
}
|
||||
|
||||
function onCancel(e: PointerEvent): void {
|
||||
if (e.pointerType !== "touch") return;
|
||||
touches.delete(e.pointerId);
|
||||
cancelPress();
|
||||
pinch = null;
|
||||
}
|
||||
|
||||
function onNativeMenu(e: Event): void {
|
||||
if (synthetic.has(e)) return;
|
||||
if (Date.now() - longAt < AFTER_LONG_MS) {
|
||||
// 우리가 이미 냄 — 브라우저 것은 하나 더라 버림
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
} else if (press) {
|
||||
nativeMenuAt = Date.now(); // 브라우저가 먼저 냄 — 우리 것은 안 냄
|
||||
cancelPress();
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e: Event): void {
|
||||
if (synthetic.has(e) || Date.now() - longAt >= AFTER_LONG_MS) return;
|
||||
longAt = 0;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function listen(): void {
|
||||
const opts = { capture: true, passive: true } as const;
|
||||
window.addEventListener("pointerdown", onDown, opts);
|
||||
window.addEventListener("pointermove", onMove, opts);
|
||||
window.addEventListener("pointerup", onUp, opts);
|
||||
window.addEventListener("pointercancel", onCancel, opts);
|
||||
window.addEventListener("contextmenu", onNativeMenu, true);
|
||||
window.addEventListener("click", onClick, true);
|
||||
window.addEventListener(
|
||||
"dblclick",
|
||||
(e) => {
|
||||
if (!synthetic.has(e)) nativeDblAt = Date.now();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// 브라우저에서만(노드 시험이 이 모듈을 거쳐 불러도 됨)
|
||||
if (typeof window !== "undefined" && typeof window.addEventListener === "function") listen();
|
||||
@@ -560,7 +560,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
height: min(680px, calc(100vh - 104px));
|
||||
height: min(680px, calc(100dvh - 104px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.b03-file {
|
||||
min-height: calc(100vh - var(--app-header-height, 0px));
|
||||
min-height: calc(100dvh - var(--app-header-height, 0px));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--color-canvas, #ffffff) 0%,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createTempPicker(
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b03-file__modal b03-file__temp-modal";
|
||||
modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
|
||||
|
||||
@@ -225,10 +225,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
if (options.blocked?.() || !controls.enabled) return;
|
||||
if (event.button === 1 && panPointerId === null) {
|
||||
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
|
||||
pickPivot(event);
|
||||
panAnchor.copy(pivot);
|
||||
getCamera().getWorldDirection(viewDirection);
|
||||
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
|
||||
startGrabPan(event);
|
||||
panPointerId = event.pointerId;
|
||||
element.setPointerCapture?.(event.pointerId);
|
||||
return;
|
||||
@@ -251,11 +248,15 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
||||
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
||||
function onWheel(event: WheelEvent): void {
|
||||
const camera = getCamera();
|
||||
if (!controls.enabled || options.blocked?.()) return;
|
||||
event.preventDefault();
|
||||
pickPivot(event);
|
||||
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
||||
zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP);
|
||||
}
|
||||
|
||||
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
|
||||
function zoomAround(at: { clientX: number; clientY: number }, factor: number): void {
|
||||
const camera = getCamera();
|
||||
pickPivot(at);
|
||||
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
|
||||
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
|
||||
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
|
||||
@@ -276,19 +277,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
const camera = getCamera();
|
||||
if (panPointerId === event.pointerId) {
|
||||
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
pointer.set(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
|
||||
const delta = panAnchor.clone().sub(panPoint);
|
||||
camera.position.add(delta);
|
||||
controls.target.add(delta);
|
||||
controls.update();
|
||||
grabPanTo(event);
|
||||
return;
|
||||
}
|
||||
if (pointerId !== event.pointerId) return;
|
||||
@@ -351,6 +340,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
syncPivotMarker();
|
||||
}
|
||||
|
||||
/** 그랩 팬 — 잡은 점이 커서(손가락 가운데) 아래에 계속 오도록 카메라·target을 평행 이동한다. */
|
||||
function grabPanTo(at: { clientX: number; clientY: number }): void {
|
||||
const camera = getCamera();
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
pointer.set(
|
||||
((at.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((at.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
|
||||
const delta = panAnchor.clone().sub(panPoint);
|
||||
camera.position.add(delta);
|
||||
controls.target.add(delta);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function startGrabPan(at: { clientX: number; clientY: number }): void {
|
||||
pickPivot(at);
|
||||
panAnchor.copy(pivot);
|
||||
getCamera().getWorldDirection(viewDirection);
|
||||
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
|
||||
}
|
||||
|
||||
// 손가락(기기대응 3-1) — 하나 = 회전(왼쪽 단추와 같음) · 둘 = 벌려 확대 · 같이 끌어 이동.
|
||||
// 3D 는 자체 처리 — 손가락 번역(`touch_bridge`)은 이 요소를 건너뜀.
|
||||
element.dataset.touchOwn = "3d";
|
||||
element.style.touchAction = "none";
|
||||
const touches = new Map<number, { clientX: number; clientY: number }>();
|
||||
let pinchDist = 0;
|
||||
const touchMid = (): { clientX: number; clientY: number; dist: number } => {
|
||||
const [a, b] = [...touches.values()];
|
||||
return {
|
||||
clientX: (a.clientX + b.clientX) / 2,
|
||||
clientY: (a.clientY + b.clientY) / 2,
|
||||
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
|
||||
};
|
||||
};
|
||||
function onTouchDown(event: PointerEvent): void {
|
||||
if (event.pointerType !== "touch" || options.blocked?.() || !controls.enabled) return;
|
||||
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
|
||||
if (touches.size !== 2) return;
|
||||
stop(); // 한 손가락 회전을 멈추고 두 손가락으로
|
||||
const m = touchMid();
|
||||
pinchDist = m.dist;
|
||||
startGrabPan(m);
|
||||
}
|
||||
function onTouchMove(event: PointerEvent): void {
|
||||
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
|
||||
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
|
||||
if (touches.size !== 2) return;
|
||||
const m = touchMid();
|
||||
grabPanTo(m);
|
||||
if (pinchDist > 0 && m.dist > 0) zoomAround(m, pinchDist / m.dist);
|
||||
pinchDist = m.dist;
|
||||
startGrabPan(m); // 확대 뒤 다시 잡음 — 다음 이동이 새 배율 기준
|
||||
}
|
||||
function onTouchUp(event: PointerEvent): void {
|
||||
touches.delete(event.pointerId);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
|
||||
element.releasePointerCapture(pointerId);
|
||||
@@ -381,6 +431,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
element.addEventListener("pointercancel", onPointerEnd);
|
||||
element.addEventListener("pointerleave", onPointerLeave);
|
||||
element.addEventListener("wheel", onWheel, { passive: false });
|
||||
element.addEventListener("pointerdown", onTouchDown);
|
||||
element.addEventListener("pointermove", onTouchMove);
|
||||
element.addEventListener("pointerup", onTouchUp);
|
||||
element.addEventListener("pointercancel", onTouchUp);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("pointerdown", onPointerDown);
|
||||
@@ -389,6 +443,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
element.removeEventListener("pointercancel", onPointerEnd);
|
||||
element.removeEventListener("pointerleave", onPointerLeave);
|
||||
element.removeEventListener("wheel", onWheel);
|
||||
element.removeEventListener("pointerdown", onTouchDown);
|
||||
element.removeEventListener("pointermove", onTouchMove);
|
||||
element.removeEventListener("pointerup", onTouchUp);
|
||||
element.removeEventListener("pointercancel", onTouchUp);
|
||||
if (pivotMarker) {
|
||||
pivotMarker.removeFromParent();
|
||||
pivotMarker.material.map?.dispose();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
@@ -546,7 +547,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
wheelWidth,
|
||||
8,
|
||||
);
|
||||
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
|
||||
scale = Math.min(maxScale, Math.max(0.5, scale * (wheelZoomsIn(event) ? 1.15 : 0.87)));
|
||||
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
|
||||
// screen = center + (base - center)·scale + offset 이므로,
|
||||
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
min-height: calc(100dvh - var(--app-header-h));
|
||||
}
|
||||
|
||||
.b04-surface__workspace > .b04-surface__status {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import {
|
||||
computeMaxScale,
|
||||
lonLatToScreen,
|
||||
@@ -67,7 +68,7 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
|
||||
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
|
||||
// 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다.
|
||||
const scale = clampWheelScale(
|
||||
prevScale * (event.deltaY > 0 ? 1.15 : 0.87),
|
||||
prevScale * (wheelZoomsIn(event) ? 1.15 : 0.87),
|
||||
prevScale,
|
||||
params.getMinScale(),
|
||||
maxScale,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
@@ -196,7 +197,7 @@ export function createPipeEditor(
|
||||
if (!xy) continue;
|
||||
const screen = metricToScreen(view, xy.x, xy.y);
|
||||
if (!screen) continue;
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) return i;
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) return i;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -221,20 +222,20 @@ export function createPipeEditor(
|
||||
const metric = screenToMetric(view, screenX, screenY);
|
||||
if (!metric) return false;
|
||||
const nearest = nearestChainage(metric.x, metric.y);
|
||||
return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX;
|
||||
return !!nearest && nearest.distance * pxPerMeter(view) <= pickPx(ADD_SNAP_PX);
|
||||
},
|
||||
chainageAt(view, screenX, screenY) {
|
||||
const metric = screenToMetric(view, screenX, screenY);
|
||||
if (!metric) return null;
|
||||
const nearest = nearestChainage(metric.x, metric.y);
|
||||
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null;
|
||||
if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return null;
|
||||
return onRoute(nearest.chainage);
|
||||
},
|
||||
addAt(view, screenX, screenY) {
|
||||
const metric = screenToMetric(view, screenX, screenY);
|
||||
if (!metric) return false;
|
||||
const nearest = nearestChainage(metric.x, metric.y);
|
||||
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false;
|
||||
if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return false;
|
||||
const at = onRoute(nearest.chainage);
|
||||
pipeList.push({ chainage_m: at, reason: "confirmed" });
|
||||
sortPipes();
|
||||
@@ -253,7 +254,7 @@ export function createPipeEditor(
|
||||
if (!xy) continue;
|
||||
const screen = metricToScreen(view, xy.x, xy.y);
|
||||
if (!screen) continue;
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) {
|
||||
selectedIndex = i;
|
||||
draggingIndex = i;
|
||||
dragFrom = pipeList[i].chainage_m;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import {
|
||||
irregularStationId,
|
||||
@@ -91,7 +92,7 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
|
||||
// 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다.
|
||||
const near = options.stations
|
||||
.map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) }))
|
||||
.filter((entry) => entry.distance <= GRAB_SLACK_PX)
|
||||
.filter((entry) => entry.distance <= pickPx(GRAB_SLACK_PX))
|
||||
.sort((left, right) => left.distance - right.distance)[0];
|
||||
event.preventDefault();
|
||||
const at = Number(chainage.toFixed(2));
|
||||
|
||||
@@ -205,7 +205,14 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = callbacks.freeHint();
|
||||
wrap.append(hint);
|
||||
// 자판 없는 기기 — Backspace · Enter 대신(기기대응 4-3 · 같은 키 길 · 터치 모드에서만 보임)
|
||||
const press = (key: string) => () =>
|
||||
document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
||||
const back = toolButton("점 빼기", "마지막 점 빼기 (Backspace)", press("Backspace"));
|
||||
const end = toolButton("끝", "자유지정 끝 (Enter)", press("Enter"));
|
||||
back.classList.add("ui-touch-only");
|
||||
end.classList.add("ui-touch-only");
|
||||
wrap.append(back, end, hint);
|
||||
} else if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
|
||||
@@ -45,7 +45,7 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "ui-modal__backdrop b05-routeedit";
|
||||
overlay.innerHTML = `
|
||||
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__box ui-popup" role="dialog" aria-label="계획노선 편집">
|
||||
<div class="ui-modal__top b05-routeedit__head">
|
||||
<h3>계획노선 편집</h3>
|
||||
<span class="b05-routeedit__rec-now" data-rec-now></span>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx, wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
@@ -56,7 +57,7 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
view.width,
|
||||
MAX_SCALE_FALLBACK,
|
||||
);
|
||||
const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
|
||||
const factor = wheelZoomsIn(event) ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
|
||||
const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor));
|
||||
const ratio = scale / view.scale;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -210,7 +211,7 @@ export function arcAtScreen(
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
hitPx: number = ARC_HIT_PX,
|
||||
hitPx: number = pickPx(ARC_HIT_PX),
|
||||
): number {
|
||||
let best = -1;
|
||||
let bestDistance = hitPx;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
|
||||
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
@@ -275,7 +276,13 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
picked.length === 1 ? [picked[0].line] : ["planned", "expected"];
|
||||
const hits = candidates
|
||||
.map((line) => {
|
||||
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
const hit = routePointAtScreen(
|
||||
lines[line],
|
||||
params.toScreen,
|
||||
px,
|
||||
py,
|
||||
pickPx(MEASURE_HIT_PX),
|
||||
);
|
||||
if (!hit) return null;
|
||||
const [sx, sy] = params.toScreen(hit.point);
|
||||
return { ...hit, line, distance: Math.hypot(sx - px, sy - py) };
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 그대로 건네받아 **제자리에서** 고친다(되돌리기 사진첩이 같은 배열을 본다).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import {
|
||||
@@ -112,13 +113,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
toScreen,
|
||||
px,
|
||||
py,
|
||||
NODE_HIT_PX + 2,
|
||||
pickPx(NODE_HIT_PX) + 2,
|
||||
);
|
||||
};
|
||||
const nodeAt = (px: number, py: number): number =>
|
||||
nodeAtScreen(params.state().planned, toScreen, px, py, NODE_HIT_PX);
|
||||
nodeAtScreen(params.state().planned, toScreen, px, py, pickPx(NODE_HIT_PX));
|
||||
const segmentAt = (px: number, py: number): number =>
|
||||
segmentAtScreen(params.state().planned, toScreen, px, py, SEGMENT_HIT_PX);
|
||||
segmentAtScreen(params.state().planned, toScreen, px, py, pickPx(SEGMENT_HIT_PX));
|
||||
|
||||
/** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */
|
||||
function dragHandleTo(
|
||||
@@ -191,7 +192,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
params.stationIntervalM,
|
||||
px,
|
||||
py,
|
||||
STATION_HIT_PX,
|
||||
pickPx(STATION_HIT_PX),
|
||||
stationLabelBoxes(canvas),
|
||||
);
|
||||
if (chainage === null) return false;
|
||||
@@ -221,7 +222,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
state.view,
|
||||
px,
|
||||
py,
|
||||
CONTOUR_HIT_PX,
|
||||
pickPx(CONTOUR_HIT_PX),
|
||||
params.contourStepM(),
|
||||
);
|
||||
if (hit !== state.pickedContour) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* 그래프는 매 그리기마다 새로 만들어지므로 이 레인도 그때마다 다시 붙인다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
@@ -319,7 +320,7 @@ function attachMarkDrag(
|
||||
maxOffsetPx = Math.max(maxOffsetPx, Math.abs(event.clientX - startX));
|
||||
// 여유를 벗어나기 전에는 시각적으로도 움직이지 않는다 — 클릭 중 마크가 흔들리면
|
||||
// 이동으로 오해하기 쉽다(2026-08-19 민감도 완화).
|
||||
if (maxOffsetPx <= GRAB_SLACK_PX) return;
|
||||
if (maxOffsetPx <= pickPx(GRAB_SLACK_PX)) return;
|
||||
const lane = mark.parentElement;
|
||||
if (!lane) return;
|
||||
const localX = event.clientX - lane.getBoundingClientRect().left;
|
||||
@@ -331,7 +332,7 @@ function attachMarkDrag(
|
||||
dragging = false;
|
||||
mark.releasePointerCapture(event.pointerId);
|
||||
// 여유 안에서 뗐으면 이동이 아니라 클릭이다 — 고르려다 옮겨지면 곤란하다.
|
||||
if (maxOffsetPx <= GRAB_SLACK_PX) return;
|
||||
if (maxOffsetPx <= pickPx(GRAB_SLACK_PX)) return;
|
||||
const lane = mark.parentElement;
|
||||
if (!lane) return;
|
||||
const localX = event.clientX - lane.getBoundingClientRect().left;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.b05-route-layout {
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -192,6 +192,11 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) */
|
||||
html.is-bm-route .b05-route__view-controls {
|
||||
left: max(var(--spacing-16), var(--wf-burger-pad, 0px));
|
||||
}
|
||||
|
||||
/* 방위 나침반을 놓을 자리 — ISO 버튼 바로 아래(2026-09-03 사용자 지시).
|
||||
버튼 줄 높이 34px + 위 여백 16px 아래에 붙인다. 위젯 모양·색은 공용
|
||||
(`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */
|
||||
@@ -603,3 +608,13 @@
|
||||
font-size: var(--text-caption);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* 휴대폰 폭(기기대응) — 3D 보기 단추 줄이 화면 밖으로 안 나가게 줄바꿈 */
|
||||
html[data-size="compact"] .b05-route__view-controls {
|
||||
right: var(--spacing-16);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
html[data-size="compact"] .b05-route__view-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
||||
@@ -294,7 +295,7 @@ export function pickAt(
|
||||
.sort((a, b) => areas[a] - areas[b])[0];
|
||||
if (
|
||||
paths.length &&
|
||||
gaps[nearest] <= LINE_PICK_PX &&
|
||||
gaps[nearest] <= pickPx(LINE_PICK_PX) &&
|
||||
(face === undefined || gaps[nearest] * 2 <= across)
|
||||
)
|
||||
return { piece: nearest };
|
||||
@@ -351,6 +352,21 @@ export function attachDetailSelect(input: {
|
||||
input.commit();
|
||||
});
|
||||
|
||||
// 자판 없는 기기 — Shift · Ctrl 누르고 고르기 대신 「여럿」 켜고 톡(기기대응 4-3 · 터치 모드에서만 보임)
|
||||
let multi = false;
|
||||
const multiBtn = document.createElement("button");
|
||||
multiBtn.type = "button";
|
||||
multiBtn.className = "b06-cross-card__zoom-btn ui-touch-only";
|
||||
multiBtn.textContent = "여럿";
|
||||
multiBtn.title = "선 여럿 고르기 (Shift 누르고 누르기와 같음)";
|
||||
multiBtn.setAttribute("aria-pressed", "false");
|
||||
multiBtn.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
multi = !multi;
|
||||
multiBtn.setAttribute("aria-pressed", String(multi));
|
||||
multiBtn.classList.toggle("is-on", multi);
|
||||
});
|
||||
|
||||
const show = (): void => {
|
||||
const chosen = selected.map((i) => pieces[i]);
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
|
||||
@@ -372,7 +388,8 @@ export function attachDetailSelect(input: {
|
||||
put.hidden = !mode || !selectionSource(mode, chosen);
|
||||
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
|
||||
const bar = cad.querySelector(".b06-cross-card__zoom");
|
||||
if (bar && label.parentElement !== bar) bar.prepend(label, put);
|
||||
if (bar && label.parentElement !== bar) bar.prepend(label, put, multiBtn);
|
||||
multiBtn.hidden = !mode;
|
||||
};
|
||||
|
||||
/** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */
|
||||
@@ -387,7 +404,7 @@ export function attachDetailSelect(input: {
|
||||
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const pick = pickOf(event);
|
||||
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
const shift = event.shiftKey || event.ctrlKey || event.metaKey || multi;
|
||||
const additive = shift || mode === "area";
|
||||
noteText = null;
|
||||
if (!pick || !("piece" in pick)) {
|
||||
|
||||
@@ -435,11 +435,13 @@ export function createSectionView(
|
||||
true,
|
||||
);
|
||||
// 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3).
|
||||
cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP);
|
||||
// 휴대폰 폭(기기대응 · <html data-size>)은 한 장씩 — 카드가 반으로 눌려 단추 · 면적표가 잘림
|
||||
const cols = document.documentElement.dataset.size === "compact" ? 1 : columnCount;
|
||||
cachedCardWidth = crossCardWidth(renderWidth, cols, CROSS_GRID_GAP);
|
||||
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
|
||||
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
|
||||
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`;
|
||||
grid.dataset.columns = String(columnCount); // 4열부터 면적표 「계」만(173-3 · CSS)
|
||||
grid.style.gridTemplateColumns = `repeat(${cols}, ${cachedCardWidth}px)`;
|
||||
grid.dataset.columns = String(cols); // 4열부터 면적표 「계」만(173-3 · CSS)
|
||||
// 맹암거 형상 누름 = 그 구조물 id 고름(288-2 · 노면공 포장 범위 표식과 따로 · 같은 id 고름 창구)
|
||||
grid.addEventListener(SHAPE_PICK_EVENT, (event) => {
|
||||
const id = (event as CustomEvent<string>).detail;
|
||||
@@ -460,7 +462,7 @@ export function createSectionView(
|
||||
baseHeightsOf(plotBases, cachedCardWidth, baseWidth),
|
||||
baseWidth,
|
||||
cachedCardWidth,
|
||||
columnCount,
|
||||
cols,
|
||||
);
|
||||
sections.forEach((section, index) => {
|
||||
cachedRowHeight.set(section.station_id, rowHeights[index]);
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
/* --- 좌측 입력 폼 --- */
|
||||
.b06-profile-layout {
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) */
|
||||
html.is-bm-route .b06-section__notice-bar {
|
||||
padding-left: max(0px, calc(var(--wf-burger-pad, 0px) - var(--spacing-24)));
|
||||
}
|
||||
|
||||
.b06-section__notice-blocks {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
/* 워크플로우 레이아웃 높이 (B06 패턴 준수) */
|
||||
.b07-design-layout {
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -450,6 +450,11 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) — 탭 줄이 본문 맨 위 · CAD 제목 줄은 CAD 앱이 스스로(`host-burger.ts`) */
|
||||
html.is-bm-route .b07-cad-stack > .cad-tabs {
|
||||
padding-left: max(6px, var(--wf-burger-pad, 0px));
|
||||
}
|
||||
|
||||
.b07-cad-stack > .cad-host {
|
||||
flex: 1 1 0;
|
||||
height: auto;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Aislo 2D Drawing</title>
|
||||
</head>
|
||||
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row">
|
||||
|
||||
@@ -75,7 +75,7 @@ body > canvas[data-id="canvas"] {
|
||||
left: var(--cad-panel-width);
|
||||
width: calc(100vw - var(--cad-panel-width));
|
||||
height: calc(
|
||||
100vh -
|
||||
100dvh -
|
||||
var(--cad-title-height) -
|
||||
var(--cad-ribbon-height) -
|
||||
var(--cad-command-height) -
|
||||
@@ -473,7 +473,7 @@ body > canvas[data-id="canvas"] {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager {
|
||||
max-height: calc(100vh - 250px);
|
||||
max-height: calc(100dvh - 250px);
|
||||
padding: 9px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
* 집기 반경 — 화면에서 몇 픽셀 안쪽을 "그 객체를 가리킨 것"으로 볼지.
|
||||
* 상수는 화면 픽셀이므로 월드 거리로 쓰려면 확대율로 나눠야 한다. 나누지 않으면
|
||||
* 확대할수록 반경이 같이 커져서(2000%에서 15 → 300px 넘음) 엉뚱한 객체가 잡힌다.
|
||||
* 손가락으로 누른 동안은 접촉 크기(최소 24px)까지 넓힘(기기대응 4-4 · `input-touch.ts` 가 정함).
|
||||
*/
|
||||
import { HIGHLIGHT_ENTITY_DISTANCE } from '../App.consts';
|
||||
import { getScreenCanvasDrawController } from '../state';
|
||||
|
||||
let touchPickPx = 0;
|
||||
|
||||
/** 0 = 마우스(원래 반경) */
|
||||
export function setTouchPickPx(px: number): void {
|
||||
touchPickPx = px;
|
||||
}
|
||||
|
||||
export function pickRadius(): number {
|
||||
return HIGHLIGHT_ENTITY_DISTANCE / getScreenCanvasDrawController().getScreenScale();
|
||||
return (
|
||||
Math.max(HIGHLIGHT_ENTITY_DISTANCE, touchPickPx) /
|
||||
getScreenCanvasDrawController().getScreenScale()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { drawInputField } from './input-field-draw.ts';
|
||||
import { ctrlShortcut } from './input-shortcut.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import { attachTouch } from './input-touch.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
ABSOLUTE_POLAR_REGEXP,
|
||||
@@ -120,6 +121,7 @@ export class InputController {
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
attachTouch(this); // 손가락(기기대응 4-1) — 마우스 경로는 그대로
|
||||
}
|
||||
|
||||
public draw(drawController: ScreenCanvasDrawController) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* 손가락 기기 명령 단추(기기대응 4-1 · `input-touch.ts`) — 자판 없이 Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅.
|
||||
마우스만 있는 화면엔 안 보임. 그림 면 오른쪽 가운데에 세로로. */
|
||||
.cad-touch-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (any-pointer: coarse) {
|
||||
.cad-touch-bar {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.cad-touch-bar > button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font-size: 18px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { setTouchPickPx } from '../helpers/pick-radius.ts';
|
||||
import { getCanvas, getScreenCanvasDrawController } from '../state.ts';
|
||||
import type { InputController } from './input-controller.ts';
|
||||
import './input-touch.css';
|
||||
|
||||
/**
|
||||
* 손가락 CAD(기기대응 4-1) — 마우스 코드는 그대로 두고 손가락만 여기서 번역한다.
|
||||
*
|
||||
* · 톡 = 그 자리 왼쪽 클릭(스냅 그대로)
|
||||
* · 한 손가락 끌기 = 화면 옮기기 · 두 손가락 = 벌려 확대 + 같이 끌어 옮기기
|
||||
* · 길게 누름(450ms) = 정밀 고르기 — 손가락 위 70px 에 십자선 · 끌어 맞추고 떼면 그 자리 클릭
|
||||
* · 고름 반경 = 접촉 크기(최소 24px) · 마우스로 누르면 원래 반경
|
||||
* · 자판 없이 쓰는 명령 단추(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅) — 손가락 기기에서만 보임(App.css)
|
||||
*/
|
||||
const LONG_MS = 450;
|
||||
const SLOP_PX = 10;
|
||||
const LIFT_PX = 70;
|
||||
const MIN_PICK_PX = 24;
|
||||
|
||||
type Mode = 'idle' | 'pending' | 'pan' | 'precise' | 'pinch' | 'done';
|
||||
|
||||
export function attachTouch(ctl: InputController): void {
|
||||
const canvas = getCanvas();
|
||||
if (!canvas) return;
|
||||
canvas.style.touchAction = 'none';
|
||||
const touches = new Map<number, { x: number; y: number }>();
|
||||
let mode: Mode = 'idle';
|
||||
let start = { x: 0, y: 0 };
|
||||
let last = { x: 0, y: 0 };
|
||||
let pinchDist = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const mouse = (type: string, x: number, y: number) =>
|
||||
new MouseEvent(type, {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button: 0,
|
||||
buttons: type === 'mousedown' ? 1 : 0,
|
||||
});
|
||||
/** 화면(캔버스 아래 원점) 좌표 — 컨트롤러와 같은 꼴 */
|
||||
const canvasPoint = (x: number, y: number): Point => {
|
||||
const b = canvas.getBoundingClientRect();
|
||||
return new Point(x - b.left, b.bottom - y);
|
||||
};
|
||||
const clickAt = (x: number, y: number): void => {
|
||||
ctl.handleMouseMove(mouse('mousemove', x, y));
|
||||
ctl.handleMouseDown(mouse('mousedown', x, y));
|
||||
ctl.handleMouseUp(mouse('mouseup', x, y));
|
||||
};
|
||||
const pair = () => {
|
||||
const [a, b] = [...touches.values()];
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType !== 'touch') {
|
||||
setTouchPickPx(0); // 마우스 · 펜 = 원래 반경
|
||||
return;
|
||||
}
|
||||
e.preventDefault(); // 호환 마우스 이벤트를 막음 — 여기서 다 처리
|
||||
setTouchPickPx(Math.max(MIN_PICK_PX, e.width || 0, e.height || 0));
|
||||
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
clearTimeout(timer);
|
||||
if (touches.size === 1) {
|
||||
mode = 'pending';
|
||||
start = last = { x: e.clientX, y: e.clientY };
|
||||
timer = setTimeout(() => {
|
||||
if (mode !== 'pending') return;
|
||||
mode = 'precise';
|
||||
ctl.handleMouseMove(mouse('mousemove', last.x, last.y - LIFT_PX));
|
||||
}, LONG_MS);
|
||||
} else if (touches.size === 2) {
|
||||
mode = 'pinch';
|
||||
const m = pair();
|
||||
pinchDist = m.dist;
|
||||
last = { x: m.x, y: m.y };
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerType !== 'touch' || !touches.has(e.pointerId)) return;
|
||||
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
const view = getScreenCanvasDrawController();
|
||||
if (mode === 'pending' && Math.hypot(e.clientX - start.x, e.clientY - start.y) > SLOP_PX) {
|
||||
clearTimeout(timer);
|
||||
mode = 'pan';
|
||||
}
|
||||
if (mode === 'pan') {
|
||||
// 캔버스 좌표는 y 가 위로 — 끈 만큼 도면이 따라옴
|
||||
view.panScreen(e.clientX - last.x, last.y - e.clientY);
|
||||
last = { x: e.clientX, y: e.clientY };
|
||||
} else if (mode === 'precise') {
|
||||
last = { x: e.clientX, y: e.clientY };
|
||||
ctl.handleMouseMove(mouse('mousemove', e.clientX, e.clientY - LIFT_PX));
|
||||
} else if (mode === 'pinch' && touches.size === 2) {
|
||||
const m = pair();
|
||||
view.panScreen(m.x - last.x, last.y - m.y);
|
||||
view.setScreenMouseLocation(canvasPoint(m.x, m.y));
|
||||
if (pinchDist > 0 && m.dist > 0) view.zoomScreen(-Math.log(m.dist / pinchDist), 1);
|
||||
pinchDist = m.dist;
|
||||
last = { x: m.x, y: m.y };
|
||||
}
|
||||
});
|
||||
|
||||
const lift = (e: PointerEvent, cancelled: boolean): void => {
|
||||
if (e.pointerType !== 'touch' || !touches.delete(e.pointerId)) return;
|
||||
clearTimeout(timer);
|
||||
if (!cancelled && touches.size === 0) {
|
||||
if (mode === 'pending') clickAt(e.clientX, e.clientY);
|
||||
else if (mode === 'precise') clickAt(e.clientX, e.clientY - LIFT_PX);
|
||||
}
|
||||
// 두 손가락에서 하나를 떼면 남은 손가락은 다 뗄 때까지 아무 일도 안 함
|
||||
mode = touches.size === 0 ? 'idle' : 'done';
|
||||
};
|
||||
canvas.addEventListener('pointerup', (e) => lift(e, false));
|
||||
canvas.addEventListener('pointercancel', (e) => lift(e, true));
|
||||
|
||||
mountTouchBar(ctl);
|
||||
}
|
||||
|
||||
/** 자판 없는 명령 단추 — 단축키와 같은 길(`handleKeyStroke`)로 보낸다 */
|
||||
function mountTouchBar(ctl: InputController): void {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'cad-touch-bar';
|
||||
const keys: [string, string, KeyboardEventInit][] = [
|
||||
['Esc', '취소(Esc)', { key: 'Escape' }],
|
||||
['↵', '확정(Enter)', { key: 'Enter' }],
|
||||
['🗑', '지움(Delete)', { key: 'Delete' }],
|
||||
['↶', '되돌리기(Ctrl+Z)', { key: 'z', code: 'KeyZ', ctrlKey: true }],
|
||||
['↷', '다시(Ctrl+Y)', { key: 'y', code: 'KeyY', ctrlKey: true }],
|
||||
['⌖', '스냅 켬/끔(F3)', { key: 'F3' }],
|
||||
];
|
||||
for (const [label, title, init] of keys) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.setAttribute('aria-label', title);
|
||||
b.addEventListener('click', () => ctl.handleKeyStroke(new KeyboardEvent('keydown', init)));
|
||||
bar.append(b);
|
||||
}
|
||||
document.body.append(bar);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 앱 ☰ 자리 비키기(기기대응 7-2) — 값은 host-burger.ts 가 적음 · 틀 없이 띄우면 0 */
|
||||
html .cad-titlebar {
|
||||
padding-left: max(12px, var(--host-burger-pad, 0px));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 앱 ☰ 자리 비키기(기기대응 7-2) — 앱(B07)이 왼쪽 위에 띄운 ☰ 가 이 틀(iframe) 맨 위 줄에 걸치면
|
||||
* 걸친 폭 + 틈 8 을 `--host-burger-pad` 로 적음(App.css `.cad-titlebar` 가 그만큼 왼쪽을 비움).
|
||||
* 좌측 패널을 여닫으면 틀 폭이 바뀌어 resize 로 다시 잼 · 홀로 띄운 CAD(틀 없음)는 0.
|
||||
*/
|
||||
import './host-burger.css';
|
||||
|
||||
export function registerHostBurgerPad(): void {
|
||||
const frame = window.frameElement;
|
||||
if (!frame) return;
|
||||
const update = (): void => {
|
||||
const b = frame.ownerDocument.querySelector('.app-burger')?.getBoundingClientRect();
|
||||
const f = frame.getBoundingClientRect();
|
||||
const pad = b?.width && b.bottom > f.top ? Math.max(0, Math.ceil(b.right + 8 - f.left)) : 0;
|
||||
document.documentElement.style.setProperty('--host-burger-pad', `${pad}px`);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { InputController } from './inputController/input-controller.ts';
|
||||
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
|
||||
import { registerAisloTableExport } from './integration/aislo-table-export.ts';
|
||||
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
|
||||
import { registerHostBurgerPad } from './integration/host-burger.ts';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getCanvas,
|
||||
@@ -158,6 +159,7 @@ function initApplication() {
|
||||
registerAisloDrawingBridge();
|
||||
registerAisloTableExport(); // 고른 범위 → 면적산출 표(M02 83장)
|
||||
registerDxfClipPaste();
|
||||
registerHostBurgerPad(); // 앱 ☰ 자리 비키기(기기대응 7-2)
|
||||
registerCadDebugHook();
|
||||
registerAutoSave();
|
||||
const screenCanvasDrawController = new ScreenCanvasDrawController(context);
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100vh − N)) · 넘치면 카드 몸만 스크롤 */
|
||||
/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100dvh − N)) · 넘치면 카드 몸만 스크롤 */
|
||||
.b08-hydro__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 240px);
|
||||
max-height: calc(100dvh - 240px);
|
||||
}
|
||||
|
||||
.b08-hydro__card .ui-card__body {
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) */
|
||||
html.is-bm-route .b08-quantity > .b08-quantity__tabs:first-child {
|
||||
padding-left: max(0px, calc(var(--wf-burger-pad, 0px) - var(--spacing-24)));
|
||||
}
|
||||
|
||||
/* 구조물 집계표 보조 탭 줄 자리(170-4) — 단추는 높이만 채움 */
|
||||
.b08-quantity__tabs--blank > .ui-btn {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) */
|
||||
html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
|
||||
padding-left: max(0px, calc(var(--wf-burger-pad, 0px) - var(--spacing-24)));
|
||||
}
|
||||
|
||||
/* 아래 탭 없는 위 탭 — 보이지 않는 단추 한 줄로 높이만 채움(탭 바꿀 때 몸통이 안 튐) */
|
||||
.b09-estimation__tabs--blank > .ui-btn {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -113,7 +113,7 @@ export function openRowPopup(o: PopupOpen): void {
|
||||
listen();
|
||||
const level = stack.length;
|
||||
const box = document.createElement("div");
|
||||
box.className = "b09-estimation b09-popup";
|
||||
box.className = "b09-estimation b09-popup ui-popup";
|
||||
box.setAttribute("role", "dialog");
|
||||
box.setAttribute("aria-label", tabName(tab));
|
||||
box.dataset.b09Popup = tab;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ function openTablePick(onPick: (table: TableHead) => void): void {
|
||||
const search = createInputField({ type: "search", placeholder: tl("Copy_PickSearch") });
|
||||
const list = el("div", { className: "m01-logic__pick-list" });
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick",
|
||||
className: "m01-logic__pick ui-popup",
|
||||
attrs: { role: "dialog", "aria-label": tl("Copy_PickTitle") },
|
||||
children: [
|
||||
el("div", {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void
|
||||
const list = el("div", { className: "m01-logic__pick-list" });
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick",
|
||||
className: "m01-logic__pick ui-popup",
|
||||
attrs: { role: "dialog", "aria-label": tx("Pick_Title") },
|
||||
children: [
|
||||
el("div", {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function openPickTable(opt: PickTableOptions): void {
|
||||
const search = searchField.input;
|
||||
const host = el("div", { className: "m01-logic__pick-list" });
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01-logic__pick--table",
|
||||
className: "m01-logic__pick m01-logic__pick--table ui-popup",
|
||||
attrs: { role: "dialog", "aria-label": opt.title },
|
||||
children: [
|
||||
el("div", {
|
||||
|
||||
@@ -347,7 +347,7 @@ input.m01-logic__input {
|
||||
width: min(720px, 92vw);
|
||||
/* 위에서 고정 거리 — 공용 .ui-modal 과 같은 규칙(찾기 칸이 안 움직임) */
|
||||
max-height: var(--modal-max, 80vh);
|
||||
margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);
|
||||
margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) */
|
||||
html.is-bm-route .m01-master__headbox {
|
||||
padding-left: max(0px, calc(var(--wf-burger-pad, 0px) - var(--spacing-24)));
|
||||
}
|
||||
|
||||
.m01-master__summary {
|
||||
color: var(--color-accent);
|
||||
font-size: var(--text-body-sm);
|
||||
@@ -91,7 +96,7 @@
|
||||
}
|
||||
|
||||
.m01-master__grid-wrap {
|
||||
max-height: calc(100vh - 300px);
|
||||
max-height: calc(100dvh - 300px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
|
||||
@@ -458,11 +458,13 @@ export function renderDiagram(
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함
|
||||
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함(마우스)
|
||||
view.addEventListener(
|
||||
"pointerdown",
|
||||
(ev) => {
|
||||
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
|
||||
// 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3)
|
||||
const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world);
|
||||
if (ev.button === 1 || (ev.button === 0 && spaceDown) || touchBlank) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragPan(ev);
|
||||
|
||||
@@ -121,7 +121,7 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
|
||||
.m02-dfpanel-sheet {
|
||||
position: relative;
|
||||
height: max(640px, calc(100vh - 260px));
|
||||
height: max(640px, calc(100dvh - 260px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
표 양식일 때는 페이지 쪽 모양 그대로 두려고 도면을 품은 때만 건다. */
|
||||
.m02-main:has(> .m02-main__host > .m02-drawing) {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
.m02-itempick .m01-master__grid-wrap {
|
||||
max-height: min(60vh, calc(100vh - 300px));
|
||||
max-height: min(60vh, calc(100dvh - 300px));
|
||||
}
|
||||
|
||||
.ui-modal.m02-itempick__dialog {
|
||||
|
||||
@@ -312,3 +312,10 @@
|
||||
.m02-labordg__card.is-material {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 손가락 기기 — 판이 끌기 · 두 손가락 확대를 받음(브라우저 확대 · 밀기 안 함 · 기기대응 3-2) */
|
||||
@media (any-pointer: coarse) {
|
||||
.m02-labordg__viewport {
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,9 +280,10 @@ function mountShell(host: HTMLElement): State {
|
||||
}),
|
||||
viewport,
|
||||
);
|
||||
// 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기
|
||||
// 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기 · 손가락 빈 곳 끌기(기기대응 3-3)
|
||||
viewport.addEventListener("pointerdown", (e) => {
|
||||
if (!(e.button === 1 || (e.button === 0 && st.space))) return;
|
||||
const touchBlank = e.pointerType === "touch" && (e.target === viewport || e.target === world);
|
||||
if (!(e.button === 1 || (e.button === 0 && st.space) || touchBlank)) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
viewport.setPointerCapture(e.pointerId);
|
||||
|
||||
@@ -252,7 +252,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
});
|
||||
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01-combo",
|
||||
className: "m01-logic__pick m01-combo ui-popup",
|
||||
attrs: { role: "dialog", "aria-label": tc("Pick_Title") },
|
||||
children: [
|
||||
el("div", {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
/* 카드가 보이면 상세 칸은 창 높이까지 — 표 + 카드가 넘치면 상세 칸 전체가 스크롤 */
|
||||
.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) {
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100vh - var(--spacing-64));
|
||||
max-height: calc(100dvh - var(--app-header-h));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
/* ? 도움말 상자 — 카드 오른쪽 위(제목 줄 · 부제 아래 · 도면 · 엑셀 카드) */
|
||||
.m02-struct__card > .m02-struct__keys {
|
||||
top: calc(var(--spacing-64) + var(--spacing-8));
|
||||
top: calc(var(--app-header-h) + var(--spacing-8));
|
||||
}
|
||||
.m02-struct__keys {
|
||||
position: absolute;
|
||||
|
||||
@@ -148,7 +148,21 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
add.hidden = !opt.canEdit;
|
||||
// 「N개 고름」 · 「+」 — 층 단추 줄 오른쪽 끝(30-2 · 부르는 쪽이 붙임)
|
||||
const status = el("span", { className: "m02-tree__picked", attrs: { hidden: "" } });
|
||||
const tools = el("span", { className: "m02-tree__tools", children: [status, add] });
|
||||
// 자판 없는 기기 — Ctrl 누르고 고르기 대신 「여럿」 켜고 톡(기기대응 4-3 · 터치 모드에서만 보임)
|
||||
let multi = false;
|
||||
const multiBtn = createButton({
|
||||
label: "여럿",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
multi = !multi;
|
||||
multiBtn.classList.toggle("is-on", multi);
|
||||
multiBtn.setAttribute("aria-pressed", String(multi));
|
||||
},
|
||||
});
|
||||
multiBtn.classList.add("ui-touch-only");
|
||||
multiBtn.title = "여럿 고르기 (Ctrl 누르고 누르기와 같음)";
|
||||
multiBtn.setAttribute("aria-pressed", "false");
|
||||
const tools = el("span", { className: "m02-tree__tools", children: [status, multiBtn, add] });
|
||||
|
||||
/* --- 그리기 --- */
|
||||
const canCopy = !!api.paste;
|
||||
@@ -275,7 +289,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
picked,
|
||||
anchor,
|
||||
one.id,
|
||||
{ ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey },
|
||||
{ ctrl: event.ctrlKey || event.metaKey || multi, shift: event.shiftKey },
|
||||
visibleIds(),
|
||||
);
|
||||
if (got.handled) event.stopImmediatePropagation();
|
||||
|
||||
@@ -78,6 +78,11 @@
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) */
|
||||
html.is-bm-route .m02-main__head {
|
||||
padding-left: max(0px, calc(var(--wf-burger-pad, 0px) - var(--spacing-24)));
|
||||
}
|
||||
|
||||
.m02-main__title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 3rem;
|
||||
@@ -137,7 +142,7 @@
|
||||
/* 면적산출 · 재료산출 전체 편집 — `.ss` 는 height:100% 로 부모 높이를 받음 · 도면 편집과 같이 메인 칸을 화면 높이로 채움 */
|
||||
.m02-main:has(> .m02-main__host > .ss) {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -155,7 +160,7 @@
|
||||
/* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */
|
||||
.m02-main:has(> .m02-main__host > .ui-side-panels-row) {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
.m02-opts__kinds,
|
||||
.m02-opts__list {
|
||||
max-height: calc(100vh - 20rem);
|
||||
max-height: calc(100dvh - 20rem);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -167,6 +167,7 @@ const panelModule = loadTs(
|
||||
"../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"),
|
||||
{
|
||||
"../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu() {} },
|
||||
"./B05_Profile_UI_IrregularStations": {
|
||||
irregularStationId: (id) => `irregular:${id}`,
|
||||
|
||||
@@ -186,6 +186,7 @@ const panelModule = loadTs(
|
||||
"../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"),
|
||||
{
|
||||
"../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu() {} },
|
||||
"./B05_Profile_UI_IrregularStations": {
|
||||
irregularStationId: (id) => `irregular:${id}`,
|
||||
|
||||
@@ -172,6 +172,7 @@ Promise.resolve(
|
||||
|
||||
// ⑤ 배수유역도 관 지점 — 옮김 · 우클릭 추가 = 정수 m
|
||||
const pipesEditor = loadTs("B05_Profile/B05_Profile_UI_Drainage_Pipes.ts", {
|
||||
"../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로
|
||||
"@ui/ui_template_palette": { themeColor: () => "#000" },
|
||||
"../common_util/common_util_structure_station": station,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selecti
|
||||
});
|
||||
const PREFIX = "irregular:";
|
||||
const rule = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), {
|
||||
"../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu() {} },
|
||||
"./B05_Profile_UI_IrregularStations": {
|
||||
irregularStationId: (id) => `${PREFIX}${id}`,
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_면적표_4열부터_계만_접기():
|
||||
)
|
||||
assert rule, "4~6열 EA · RR · BR 접기 규칙 없음"
|
||||
src = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "grid.dataset.columns = String(columnCount);" in src
|
||||
assert "grid.dataset.columns = String(cols);" in src # 실제 그린 열 수(휴대폰 폭 = 1 · 기기대응)
|
||||
|
||||
|
||||
# ── 173-4 [상세] 그림 ────────────────────────────────────────
|
||||
|
||||
@@ -72,7 +72,7 @@ def test_공용_표_옵션과_연결():
|
||||
def test_합계_줄_고정_그대로():
|
||||
css = _read("ui_template", "ui_template_data_table.css")
|
||||
assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S)
|
||||
assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로
|
||||
assert "max-height: calc(100dvh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -238,7 +238,7 @@ def test_창_열_제목_강조_줄_자리():
|
||||
" table: box.children[1].dataset.b09Table, left: box.style.left, top: box.style.top }));"
|
||||
)
|
||||
assert out["boxes"] == ["bill"] and out["n"] == 1
|
||||
assert out["cls"] == "b09-estimation b09-popup" # 창 안 = b09 표 꼴(빨강 테두리)
|
||||
assert out["cls"] == "b09-estimation b09-popup ui-popup" # 창 안 = b09 표 꼴(빨강 테두리)
|
||||
assert out["head"] == ["설계내역서", "탭으로", "×"]
|
||||
assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손
|
||||
assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""B07 CAD 손가락(PLAN_기기대응 4-1 · `openwebcad/src/inputController/input-touch.ts`) — 빌드된 CAD 화면.
|
||||
|
||||
① 손가락 기기 = 명령 단추 6개(44px) · 마우스 기기 = 안 보임 ② 확대 막기(user-scalable=no) 없음
|
||||
③ 캔버스 touch-action none · 두 손가락 벌림 · 한 손가락 끌기가 페이지를 확대 · 밀지 않음(페이지 오류 0)
|
||||
"""
|
||||
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
URL = "http://localhost:5174/b07-cad/"
|
||||
|
||||
|
||||
def _up() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(URL, timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _touch(cdp, kind, points):
|
||||
cdp.send(
|
||||
"Input.dispatchTouchEvent",
|
||||
{
|
||||
"type": kind,
|
||||
"touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐")
|
||||
def test_cad_touch_bar_and_gestures():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
mouse = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
mouse.goto(URL)
|
||||
mouse.wait_for_selector(".cad-touch-bar", state="attached", timeout=20000)
|
||||
assert (
|
||||
mouse.evaluate("getComputedStyle(document.querySelector('.cad-touch-bar')).display")
|
||||
== "none"
|
||||
)
|
||||
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 1180, "height": 820}, has_touch=True, is_mobile=True
|
||||
)
|
||||
page = ctx.new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.goto(URL)
|
||||
page.wait_for_selector(".cad-touch-bar", timeout=20000)
|
||||
sizes = page.evaluate(
|
||||
"[...document.querySelectorAll('.cad-touch-bar > button')].map(b => [b.offsetWidth, b.offsetHeight])"
|
||||
)
|
||||
assert len(sizes) == 6 and all(w >= 44 and h >= 44 for w, h in sizes), sizes
|
||||
assert "user-scalable" not in page.evaluate(
|
||||
"document.querySelector('meta[name=viewport]').content"
|
||||
)
|
||||
assert (
|
||||
page.evaluate(
|
||||
"getComputedStyle(document.querySelector('canvas[data-id=canvas]')).touchAction"
|
||||
)
|
||||
== "none"
|
||||
)
|
||||
|
||||
box = page.evaluate(
|
||||
"(() => { const r = document.querySelector('canvas[data-id=canvas]').getBoundingClientRect();"
|
||||
" return [r.left + r.width / 2, r.top + r.height / 2]; })()"
|
||||
)
|
||||
cx, cy = box
|
||||
cdp = ctx.new_cdp_session(page)
|
||||
_touch(cdp, "touchStart", [(cx, cy)]) # 한 손가락 끌기 = 화면 옮기기
|
||||
for i in range(1, 6):
|
||||
_touch(cdp, "touchMove", [(cx + i * 20, cy + i * 10)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
_touch(cdp, "touchStart", [(cx - 30, cy), (cx + 30, cy)]) # 벌림 = 도면 확대
|
||||
for i in range(1, 8):
|
||||
_touch(cdp, "touchMove", [(cx - 30 - i * 10, cy), (cx + 30 + i * 10, cy)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.locator(".cad-touch-bar > button").first.tap() # Esc
|
||||
page.wait_for_timeout(300)
|
||||
assert page.evaluate("visualViewport.scale") == 1
|
||||
assert page.evaluate("[scrollX, scrollY]") == [0, 0]
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""기기 대응 자동 점검(요구 193~195 · PLAN_기기대응 1-1 · 5-1).
|
||||
|
||||
sub1 서버(5174)에서 모든 페이지를 네 크기로 열어 문제 수를 셈:
|
||||
가로 넘침 · 화면 밖(밀어도 못 닿음) · 가려진 단추 · 44px 미만 누를 자리 · 닿을 수 없는 위 메뉴.
|
||||
데스크탑(1440×900 마우스)은 사진 + 배치 서명(보이는 요소 사각형)을 남겨 전후 대조.
|
||||
|
||||
실행:
|
||||
python resources/tester/test_device_audit.py before → tmp/device_audit_before.json · tmp/desktop_before/
|
||||
python resources/tester/test_device_audit.py after → tmp/device_audit_after.json · tmp/desktop_after/
|
||||
pytest — 서버가 떠 있으면 한 바퀴(after) 돌려 페이지가 모두 열리는지 · 데스크탑 배치가 before 와 같은지 봄.
|
||||
로그인 = tmp/aislo_cred.json(값은 출력하지 않음) · 프로젝트 = 검증_sub1.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BASE = "http://localhost:5174"
|
||||
PROJECT_NAME = "검증_sub1"
|
||||
PAGES = [
|
||||
"a01-home",
|
||||
"a02-prog-detail",
|
||||
"a03-comp-detail",
|
||||
"a04-news-history",
|
||||
"a05-edu-detail",
|
||||
"a06-login",
|
||||
"a07-register",
|
||||
"a08-support",
|
||||
"a09-security",
|
||||
"b01-account",
|
||||
"b02-proj-register",
|
||||
"b03-file-input",
|
||||
"b04-preprocess",
|
||||
"b05-profile",
|
||||
"b06-section",
|
||||
"b07-design-detail",
|
||||
"b08-quantity",
|
||||
"b09-estimation",
|
||||
"b10-payment",
|
||||
"b11-status",
|
||||
"m01-master-data",
|
||||
"m02-master-template",
|
||||
]
|
||||
SIZES = {
|
||||
"phone_390x844_touch": {"viewport": {"width": 390, "height": 844}, "touch": True},
|
||||
"tablet_820x1180_touch": {"viewport": {"width": 820, "height": 1180}, "touch": True},
|
||||
"tablet_1180x820_touch": {"viewport": {"width": 1180, "height": 820}, "touch": True},
|
||||
"desktop_1440x900_mouse": {"viewport": {"width": 1440, "height": 900}, "touch": False},
|
||||
}
|
||||
DESKTOP = "desktop_1440x900_mouse"
|
||||
|
||||
# 페이지 안에서 셈 — 숫자만 돌려줌(값 · 글은 안 담음)
|
||||
AUDIT_JS = r"""
|
||||
() => {
|
||||
const vw = innerWidth, vh = innerHeight;
|
||||
const SEL = 'button, a[href], input:not([type=hidden]), select, textarea, summary, ' +
|
||||
'[role=button], [role=menuitem], [role=tab], [role=checkbox], [tabindex]:not([tabindex="-1"])';
|
||||
const vis = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) return null;
|
||||
if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null;
|
||||
return r;
|
||||
};
|
||||
const scrollable = (el, axis) => {
|
||||
const cs = getComputedStyle(el);
|
||||
const ov = axis === 'x' ? cs.overflowX : cs.overflowY;
|
||||
if (!/(auto|scroll)/.test(ov)) return false;
|
||||
return axis === 'x' ? el.scrollWidth > el.clientWidth + 1 : el.scrollHeight > el.clientHeight + 1;
|
||||
};
|
||||
const se = document.scrollingElement;
|
||||
const rootCs = getComputedStyle(document.documentElement), bodyCs = getComputedStyle(document.body);
|
||||
const docScroll = {
|
||||
x: se.scrollWidth > vw + 1 && !/hidden|clip/.test(rootCs.overflowX + bodyCs.overflowX),
|
||||
y: se.scrollHeight > vh + 1 && !/hidden|clip/.test(rootCs.overflowY + bodyCs.overflowY),
|
||||
};
|
||||
const reachable = (el, axis) => {
|
||||
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
||||
if (scrollable(p, axis)) return true;
|
||||
if (getComputedStyle(p).position === 'fixed') return false;
|
||||
}
|
||||
return docScroll[axis];
|
||||
};
|
||||
const out = { hscroll_px: Math.max(0, se.scrollWidth - vw), offscreen: 0, covered: 0, small: 0,
|
||||
targets: 0, menu_unreachable: 0, too_wide: 0, samples: { offscreen: [], covered: [], small: [] } };
|
||||
const name = (el) => {
|
||||
const t = (el.getAttribute('aria-label') || el.title || el.textContent || el.placeholder || '').trim().slice(0, 24);
|
||||
return `${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.split(/\s+/)[0] : ''}:${t}`;
|
||||
};
|
||||
const push = (k, el) => { if (out.samples[k].length < 8) out.samples[k].push(name(el)); };
|
||||
for (const el of document.querySelectorAll(SEL)) {
|
||||
if (el.closest('[aria-hidden=true], [inert]')) continue;
|
||||
const r = vis(el);
|
||||
if (!r) continue;
|
||||
out.targets++;
|
||||
const offX = r.right > vw + 1 || r.left < -1, offY = r.bottom > vh + 1 || r.top < -1;
|
||||
if ((offX && !reachable(el, 'x')) || (offY && !reachable(el, 'y'))) { out.offscreen++; push('offscreen', el); continue; }
|
||||
if (offX || offY) continue;
|
||||
const cx = Math.min(vw - 1, Math.max(0, r.left + r.width / 2)), cy = Math.min(vh - 1, Math.max(0, r.top + r.height / 2));
|
||||
const hit = document.elementFromPoint(cx, cy);
|
||||
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)
|
||||
&& !(el.tagName === 'INPUT' && hit.tagName === 'LABEL')) { out.covered++; push('covered', el); }
|
||||
if (Math.min(r.width, r.height) < 44) { out.small++; push('small', el); }
|
||||
}
|
||||
// 위 메뉴 — 숨은 항목 수(☰ 이 보이면 닿음)
|
||||
const nav = document.querySelector('.app-nav');
|
||||
const burger = document.querySelector('.app-burger');
|
||||
if (nav && !(burger && vis(burger))) {
|
||||
for (const b of nav.querySelectorAll('button')) if (!vis(b)) out.menu_unreachable++;
|
||||
}
|
||||
// 본문 안 화면보다 넓은 상자(고정 폭)
|
||||
const outlet = document.getElementById('app-outlet');
|
||||
if (outlet) for (const el of outlet.querySelectorAll('*')) {
|
||||
if (el.offsetWidth > vw + 1 && !reachable(el, 'x')) out.too_wide++;
|
||||
}
|
||||
out.pointer = matchMedia('(pointer: coarse)').matches ? 'coarse' : 'fine';
|
||||
out.rendered = !!(outlet && outlet.childElementCount);
|
||||
out.outlet_top = outlet ? Math.round(outlet.getBoundingClientRect().top) : null; // 본문 위 끝(위 줄 걷힘 = 0 · 6-3)
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
# ☰ 상자와 겹치는 보이는 글 · 단추(7-3) — 왼쪽 위에 뜬 ☰ 아래 깔린 것만(맨 위에 보이는 것 · 같은 출처 iframe 안 포함)
|
||||
BURGER_JS = r"""
|
||||
() => {
|
||||
const b = document.querySelector('.app-burger');
|
||||
if (!b || !b.getClientRects().length) return [];
|
||||
const B = b.getBoundingClientRect(), hits = [];
|
||||
const scan = (doc, ox, oy) => {
|
||||
const L = B.left - ox, T = B.top - oy, R = B.right - ox, D = B.bottom - oy;
|
||||
const cands = new Set(doc.querySelectorAll('button, a[href], input:not([type=hidden]), select, textarea, [role=button], [role=tab]'));
|
||||
const w = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||||
for (let n = w.nextNode(); n; n = w.nextNode()) if (n.nodeValue.trim()) cands.add(n);
|
||||
for (const c of cands) {
|
||||
const el = c.nodeType === 3 ? c.parentElement : c;
|
||||
if (!el || el.closest('.app-header, .ui-toast')) continue;
|
||||
if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue;
|
||||
let r = el.getBoundingClientRect();
|
||||
if (c.nodeType === 3) { const g = doc.createRange(); g.selectNodeContents(c); r = g.getBoundingClientRect(); }
|
||||
const l = Math.max(r.left, L), t = Math.max(r.top, T), rr = Math.min(r.right, R), bb = Math.min(r.bottom, D);
|
||||
if (r.width < 1 || r.height < 1 || rr - l < 1 || bb - t < 1) continue;
|
||||
const top = doc.elementsFromPoint((l + rr) / 2, (t + bb) / 2).find((e) => !e.closest('.app-header'));
|
||||
if (top && top !== el && !el.contains(top) && !top.contains(el)) continue;
|
||||
hits.push(`${el.tagName.toLowerCase()}:${(c.nodeType === 3 ? c.nodeValue : el.textContent).trim().slice(0, 16)}`);
|
||||
}
|
||||
for (const f of doc.querySelectorAll('iframe')) {
|
||||
const fr = f.getBoundingClientRect();
|
||||
if (fr.right < L || fr.left > R || fr.bottom < T || fr.top > D) continue;
|
||||
try { if (f.contentDocument?.body) scan(f.contentDocument, ox + fr.left + f.clientLeft, oy + fr.top + f.clientTop); } catch (e) {}
|
||||
}
|
||||
};
|
||||
scan(document, 0, 0);
|
||||
return hits;
|
||||
}
|
||||
"""
|
||||
|
||||
# 데스크탑 배치 서명 — 화면 안에 보이는 요소 사각형(정수) · 순서대로.
|
||||
# 잡음 빼기 = 움직이는 중(CSS 애니메이션 · 로딩 원)인 요소와 그 안 · 화면 밖(스크롤 아래 1px 흔들림)
|
||||
LAYOUT_JS = r"""
|
||||
() => {
|
||||
const rows = [];
|
||||
const moving = new Set(document.getAnimations().map((a) => a.effect && a.effect.target).filter(Boolean));
|
||||
const isMoving = (el) => { for (let p = el; p; p = p.parentElement) if (moving.has(p)) return true; return false; };
|
||||
for (const el of document.body.querySelectorAll('*')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) continue;
|
||||
if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) continue;
|
||||
if (isMoving(el)) continue;
|
||||
if (el.checkVisibility && !el.checkVisibility({ visibilityProperty: true })) continue;
|
||||
rows.push(`${el.tagName}|${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}`);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _project_id(ctx):
|
||||
projects = ctx.request.get(f"{BASE}/api/dashboard/user/projects").json()["projects"]
|
||||
return next(p["id"] for p in projects if p.get("name") == PROJECT_NAME)
|
||||
|
||||
|
||||
def _login(ctx, cred):
|
||||
for _ in range(3): # 서버가 바쁠 때 한 번씩 30초 넘김
|
||||
try:
|
||||
r = ctx.request.post(f"{BASE}/api/auth/login/request", data=cred, timeout=60000)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
assert r.ok, f"로그인 실패 {r.status}"
|
||||
|
||||
|
||||
def _open(page, route):
|
||||
page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded")
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
try: # 불러오는 중 표시(돌이 · 진행 원)가 사라질 때까지 — 반쯤 그린 화면을 찍지 않게
|
||||
page.wait_for_function(
|
||||
"() => ![...document.querySelectorAll('.ui-loading, .ui-progress-circle')]"
|
||||
".some((e) => e.getClientRects().length)",
|
||||
timeout=45000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
|
||||
def run_audit(tag: str, pages=PAGES, sizes=SIZES) -> dict:
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
shots = ROOT / "tmp" / f"desktop_{tag}"
|
||||
shots.mkdir(parents=True, exist_ok=True)
|
||||
result = {"base": BASE, "sizes": list(sizes), "pages": {}}
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
for size, spec in sizes.items():
|
||||
ctx = browser.new_context(
|
||||
viewport=spec["viewport"],
|
||||
has_touch=spec["touch"],
|
||||
is_mobile=spec["touch"],
|
||||
locale="ko-KR",
|
||||
)
|
||||
_login(ctx, cred)
|
||||
pid = _project_id(ctx)
|
||||
ctx.add_init_script(
|
||||
f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})"
|
||||
)
|
||||
page = ctx.new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)[:120]))
|
||||
for route in pages:
|
||||
errors.clear()
|
||||
_open(page, route)
|
||||
row = page.evaluate(AUDIT_JS)
|
||||
row["errors"] = list(errors)
|
||||
row["burger_hits"] = page.evaluate(BURGER_JS)
|
||||
if size == DESKTOP:
|
||||
page.screenshot(path=str(shots / f"{route}.png"))
|
||||
(shots / f"{route}.layout.json").write_text(
|
||||
json.dumps(page.evaluate(LAYOUT_JS)), encoding="utf-8"
|
||||
)
|
||||
result["pages"].setdefault(route, {})[size] = row
|
||||
print(
|
||||
f"{route:22} {size:24} hs={row['hscroll_px']:4} off={row['offscreen']:3} "
|
||||
f"cov={row['covered']:3} small={row['small']:3} menu={row['menu_unreachable']} "
|
||||
f"top={row['outlet_top']} burger={len(row['burger_hits'])}",
|
||||
flush=True,
|
||||
)
|
||||
ctx.close()
|
||||
browser.close()
|
||||
out = ROOT / "tmp" / f"device_audit_{tag}.json"
|
||||
out.write_text(json.dumps(result, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
return result
|
||||
|
||||
|
||||
def _boxes(rows):
|
||||
"""HTML 상자 · svg 틀만 — svg 안 선 · 글(path · g · text 등 소문자 태그)은 자료 · 날짜로 바뀌는 그림이라 뺌."""
|
||||
return [r for r in rows if r.split("|")[0].isupper() or r.startswith("svg|")]
|
||||
|
||||
|
||||
def layout_diff(route: str, a="before", b="after") -> int:
|
||||
"""데스크탑 배치 서명 차이 줄 수(0 = 같음)."""
|
||||
ra = _boxes(json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text()))
|
||||
rb = _boxes(json.loads((ROOT / "tmp" / f"desktop_{b}" / f"{route}.layout.json").read_text()))
|
||||
import difflib
|
||||
|
||||
return sum(
|
||||
1 for line in difflib.ndiff(ra, rb) if line.startswith("+ ") or line.startswith("- ")
|
||||
)
|
||||
|
||||
|
||||
def _rect(row):
|
||||
tag, xywh = row.split("|")
|
||||
return tag, tuple(int(v) for v in xywh.split(","))
|
||||
|
||||
|
||||
def bm_moved(route: str, a="before", b="after") -> list:
|
||||
"""로그인 뒤 쪽(B · M) 데스크탑 — 위 줄(64)을 걷어 세로는 올라가도 가로 자리(태그 · left · width)는 같아야 함(7-3).
|
||||
원래 상자 가운데 지금 같은 가로 자리가 없는 것. 빼는 것 = 위 줄 · ☰ 띠(위 64 + 60 안 · 들여쓴 첫 줄) ·
|
||||
없어진 떠 있는 진행 판(ASIDE) 안 · 화면 전체 상자."""
|
||||
ra = _boxes(json.loads((ROOT / "tmp" / f"desktop_{a}" / f"{route}.layout.json").read_text()))
|
||||
rb = _boxes(json.loads((ROOT / "tmp" / f"desktop_{b}" / f"{route}.layout.json").read_text()))
|
||||
import collections
|
||||
|
||||
left = collections.Counter((t, r[0], r[2]) for t, r in map(_rect, rb))
|
||||
missing = []
|
||||
for row in ra:
|
||||
t, r = _rect(row)
|
||||
if left[(t, r[0], r[2])] > 0:
|
||||
left[(t, r[0], r[2])] -= 1
|
||||
else:
|
||||
missing.append((t, r))
|
||||
gone = [r for t, r in missing if t == "ASIDE"]
|
||||
|
||||
def inside(r, g):
|
||||
return (
|
||||
g[0] <= r[0]
|
||||
and g[1] <= r[1]
|
||||
and r[0] + r[2] <= g[0] + g[2]
|
||||
and r[1] + r[3] <= g[1] + g[3]
|
||||
)
|
||||
|
||||
return [
|
||||
f"{t}|{r}"
|
||||
for t, r in missing
|
||||
if r[1] >= 64 + 60
|
||||
and r[2] < SIZES[DESKTOP]["viewport"]["width"]
|
||||
and not any(inside(r, g) for g in gone)
|
||||
]
|
||||
|
||||
|
||||
def _server_up() -> bool:
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(BASE, timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _server_up(), reason="sub1 화면 서버(5174) 꺼짐")
|
||||
def test_device_audit_all_pages_render_and_desktop_unchanged():
|
||||
result = run_audit("after")
|
||||
for route, sizes in result["pages"].items():
|
||||
for size, row in sizes.items():
|
||||
assert row["rendered"], f"{route} {size} 빈 화면"
|
||||
assert row["pointer"] == ("fine" if size == DESKTOP else "coarse"), (route, size)
|
||||
# 로그인 뒤 쪽 = ☰ 왼쪽 위 · 그 상자와 겹치는 글 · 단추 0 · 본문 위 끝 0(7-3)
|
||||
if route[0] in "bm":
|
||||
assert not row["burger_hits"], (route, size, row["burger_hits"])
|
||||
assert row["outlet_top"] == (40 if route == "b03-file-input" else 0), (route, size)
|
||||
if (ROOT / "tmp" / "desktop_before2").exists():
|
||||
# 공개 쪽 = 배치 그대로 · 로그인 뒤 쪽 = 가로 자리 그대로 — 같은 원래 코드 두 번 재기(before · before2) 잡음 이하
|
||||
def diff(r, b):
|
||||
return len(bm_moved(r, "before", b)) if r[0] in "bm" else layout_diff(r, "before", b)
|
||||
|
||||
noise = {r: diff(r, "before2") for r in PAGES}
|
||||
over = [r for r in PAGES if diff(r, "after") > noise[r]]
|
||||
if over: # 불러오는 때에 따라 한 번 흔들림(B06 종단) — 그 쪽만 한 번 더 잼
|
||||
run_audit("after_retry", pages=over, sizes={DESKTOP: SIZES[DESKTOP]})
|
||||
over = {r: diff(r, "after_retry") for r in over if diff(r, "after_retry") > noise[r]}
|
||||
assert not over, over
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tag = sys.argv[1] if len(sys.argv) > 1 else "after"
|
||||
only = sys.argv[2].split(",") if len(sys.argv) > 2 else PAGES
|
||||
run_audit(tag, pages=only)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""☰ 서랍 · 위 줄 걷기(PLAN_기기대응 6 · 7 · `app_shell.ts` · `ui_template_workflow_layout.ts`) — 실제 브라우저.
|
||||
|
||||
① 휴대폰 · 로그인 뒤 B 쪽 = 위 줄 걷힘(본문 위 끝 0) · ☰ 는 위 왼쪽 모서리(폭 36 · 손가락 높이 44) · 떠 있는 진행 판 없음
|
||||
② ☰ 서랍 = 왼쪽에서 열림 · ☰ 는 서랍 위에 그대로 · 로고 · 홈 ─ 진행 단계 ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드 차례
|
||||
진행 단계는 진행 판의 **그 요소**(한 벌) · 지금 단계 강조 그대로 · 닫으면 제자리(진행 판)로 돌아감 · 단계 누름 = 이동 + 서랍 닫힘
|
||||
③ 휴대폰 · 공개 쪽(A01) = 위 줄 그대로(본문 위 끝 64) · ☰ 는 위 줄 오른쪽
|
||||
④ 데스크탑(1440 · 마우스) · 로그인 뒤 B 쪽 = 위 줄 걷힘 · ☰ 왼쪽 위 36×36 · 진행 판 없음 · 위 줄 항목이 모두 서랍에 · Esc 로 닫힘
|
||||
⑤ 데스크탑 · 공개 쪽(A01) = 위 줄 그대로 · ☰ 없음
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_device_audit as audit # noqa: E402
|
||||
|
||||
OUTLET_TOP = "Math.round(document.getElementById('app-outlet').getBoundingClientRect().top)"
|
||||
GROUPS = "[...document.querySelectorAll('.app-drawer > *')].map(e => e.className)"
|
||||
BURGER = (
|
||||
"(() => { const r = document.querySelector('.app-burger').getBoundingClientRect();"
|
||||
" return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height),"
|
||||
" Math.round(r.right)]; })()"
|
||||
)
|
||||
PROGRESS_HIDDEN = (
|
||||
"!document.querySelector('.ui-workflow-overlay__panel--progress').getClientRects().length"
|
||||
)
|
||||
LABELS = (
|
||||
"(sel) => [...document.querySelectorAll(sel)].map(b => b.textContent.trim()).filter(Boolean)"
|
||||
)
|
||||
|
||||
|
||||
def _ctx(browser, cred, size):
|
||||
spec = audit.SIZES[size]
|
||||
ctx = browser.new_context(
|
||||
viewport=spec["viewport"], has_touch=spec["touch"], is_mobile=spec["touch"]
|
||||
)
|
||||
audit._login(ctx, cred)
|
||||
pid = audit._project_id(ctx)
|
||||
ctx.add_init_script(f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})")
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not audit._server_up(), reason="sub1 화면 서버(5174) 꺼짐")
|
||||
def test_drawer_holds_steps_and_header_folds():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
cred = json.loads((audit.ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
page = _ctx(browser, cred, "phone_390x844_touch").new_page()
|
||||
audit._open(page, "b07-design-detail")
|
||||
|
||||
# ①
|
||||
assert page.evaluate(OUTLET_TOP) == 0
|
||||
burger = page.evaluate(BURGER)
|
||||
assert burger[:4] == [8, 8, 36, 44], burger
|
||||
assert page.evaluate(PROGRESS_HIDDEN)
|
||||
|
||||
# ②
|
||||
page.evaluate(
|
||||
"window.__steps = document.querySelector('.ui-workflow-overlay__panel--progress .ui-workflow-layout__steps')"
|
||||
)
|
||||
page.evaluate("document.querySelector('.app-burger').click()")
|
||||
assert (
|
||||
page.evaluate(
|
||||
"Math.round(document.querySelector('.app-drawer').getBoundingClientRect().left)"
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert page.evaluate( # ☰ 는 서랍 위에서 그대로 눌림(다시 누르면 닫힘)
|
||||
"document.elementFromPoint(26, 30) === document.querySelector('.app-burger')"
|
||||
)
|
||||
groups = page.evaluate(GROUPS)
|
||||
order = [
|
||||
next((k for k in ("brand", "steps", "nav", "touch") if k in c), "settings")
|
||||
for c in groups
|
||||
]
|
||||
assert order == ["brand", "steps", "nav", "settings"], groups
|
||||
assert page.evaluate(
|
||||
"document.querySelector('.app-drawer__steps').firstElementChild === window.__steps"
|
||||
)
|
||||
active = page.evaluate(
|
||||
"document.querySelector('.app-drawer__steps .ui-workflow-layout__step.is-active')?.textContent"
|
||||
)
|
||||
assert active and "상세" in active, active
|
||||
page.evaluate("document.querySelector('.app-drawer__scrim').click()") # 닫기 = 제자리
|
||||
assert page.evaluate(
|
||||
"window.__steps.closest('.ui-workflow-overlay__panel--progress') !== null"
|
||||
)
|
||||
page.evaluate("document.querySelector('.app-burger').click()")
|
||||
page.evaluate(
|
||||
"[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step.is-enabled')]"
|
||||
".find(b => b.textContent.includes('수량')).click()"
|
||||
)
|
||||
page.wait_for_function("location.hash.includes('b08-quantity')", timeout=10000)
|
||||
assert not page.evaluate(
|
||||
"document.querySelector('.app-header')?.classList.contains('is-drawer-open')"
|
||||
)
|
||||
|
||||
# ③
|
||||
audit._open(page, "a01-home")
|
||||
assert page.evaluate(OUTLET_TOP) == 64
|
||||
assert page.evaluate(BURGER)[4] >= 390 - 16
|
||||
|
||||
# ④
|
||||
desk = _ctx(browser, cred, audit.DESKTOP).new_page()
|
||||
audit._open(desk, "b07-design-detail")
|
||||
assert desk.evaluate(OUTLET_TOP) == 0
|
||||
assert desk.evaluate(BURGER)[:4] == [8, 8, 36, 36]
|
||||
assert desk.evaluate(PROGRESS_HIDDEN)
|
||||
head_nav = desk.evaluate(LABELS, ".app-nav button")
|
||||
head_actions = desk.evaluate(LABELS, ".app-actions button")
|
||||
assert head_nav and head_actions
|
||||
desk.click(".app-burger")
|
||||
assert desk.evaluate(LABELS, ".app-drawer__nav button") == head_nav
|
||||
drawer_all = desk.evaluate(LABELS, ".app-drawer button")
|
||||
assert all(label in drawer_all for label in head_actions), (head_actions, drawer_all)
|
||||
assert desk.evaluate(
|
||||
"document.querySelector('.app-drawer__steps .ui-workflow-layout__step.is-active') !== null"
|
||||
)
|
||||
desk.keyboard.press("Escape")
|
||||
assert not desk.evaluate(
|
||||
"document.querySelector('.app-header').classList.contains('is-drawer-open')"
|
||||
)
|
||||
|
||||
# ⑤
|
||||
audit._open(desk, "a01-home")
|
||||
assert desk.evaluate(OUTLET_TOP) == 64
|
||||
assert not desk.evaluate("document.querySelector('.app-burger').getClientRects().length")
|
||||
browser.close()
|
||||
@@ -155,6 +155,6 @@ def test_항목_추가_창_카드_격자_표_접기():
|
||||
assert "grid-auto-rows: var(--m02-struct-card-h);" in css
|
||||
assert "--m02-struct-card-h: min(640px, 70vh);" in css # 31-6 화면 높이에 맞춤
|
||||
assert "flex: 1 0 var(--m02-struct-grid-min);" in css # 엑셀 격자가 카드 높이를 채움
|
||||
assert "max-height: calc(100vh - var(--spacing-64));\n overflow-y: auto;" in css
|
||||
assert "max-height: calc(100dvh - var(--app-header-h));\n overflow-y: auto;" in css
|
||||
main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_공용_모달_위_고정():
|
||||
assert "align-items: flex-start;" in _block(css, ".ui-modal__backdrop")
|
||||
modal = _block(css, ".ui-modal")
|
||||
assert "max-height: var(--modal-max, 80vh);" in modal
|
||||
assert "margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);" in modal
|
||||
assert "margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);" in modal
|
||||
# 큰 창은 max-height 대신 --modal-max(위 거리가 함께 맞음)
|
||||
# 고르기 창 = 공용 큰 창 곁값(147-2)
|
||||
assert "--modal-max: 94vh;" in _block(css, ".ui-modal.ui-modal--wide")
|
||||
@@ -44,7 +44,7 @@ def test_공용_모달_위_고정():
|
||||
def test_따로_둔_모달도_위_고정():
|
||||
logic = _text("M01_MasterData/M01_MasterData_UI_Logic_Style.css")
|
||||
assert "align-items: flex-start;" in _block(logic, ".m01-logic__backdrop")
|
||||
assert "margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);" in _block(
|
||||
assert "margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);" in _block(
|
||||
logic, ".m01-logic__pick"
|
||||
)
|
||||
assert "--modal-max: 90vh;" in _block(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""스프레드시트 손가락(PLAN_기기대응 4-2 · `spreadsheet_mouse.ts` · `_editor.ts` · `_grid.ts`) — 실제 브라우저 터치.
|
||||
|
||||
sub1 화면 서버(5174)에 견본 통합문서를 붙여 CDP 터치를 넣음:
|
||||
① 톡 = 칸 고름 · 화면 자판 안 뜸(inputMode none) ② 고른 칸에서 끌기 = 범위
|
||||
③ 안 고른 칸에서 끌기 = 고름 그대로(화면 밀기) ④ 두 번 톡 = 고침 · 자판 뜸(inputMode text)
|
||||
⑤ ⋯ 단추(터치 모드) = 칸 메뉴 ⑥ 자판 없는 단추 줄 · 🔍 = 찾기
|
||||
"""
|
||||
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
BASE = "http://localhost:5174"
|
||||
|
||||
MOUNT = """
|
||||
async () => {
|
||||
const m = await import('/spreadsheet/spreadsheet.ts');
|
||||
const book = await (await fetch('/spreadsheet/spreadsheet_sample.json')).json();
|
||||
const host = document.createElement('div');
|
||||
Object.assign(host.style, { position: 'fixed', inset: '0', zIndex: 99999, background: '#fff', display: 'flex' });
|
||||
document.body.append(host);
|
||||
window.__sel = null;
|
||||
window.__ss = m.createSpreadsheet(host, book, { onSelect: (s) => (window.__sel = s) });
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const g = document.querySelector('.aislo-grid').getBoundingClientRect();
|
||||
return [g.left, g.top, g.width, g.height];
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _up() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(BASE, timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _touch(cdp, kind, points):
|
||||
cdp.send(
|
||||
"Input.dispatchTouchEvent",
|
||||
{
|
||||
"type": kind,
|
||||
"touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐")
|
||||
def test_spreadsheet_touch_select_range_edit():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True
|
||||
)
|
||||
page = ctx.new_page()
|
||||
page.goto(f"{BASE}/#/a01-home")
|
||||
page.wait_for_timeout(1000)
|
||||
gx, gy, gw, gh = page.evaluate(MOUNT)
|
||||
cdp = ctx.new_cdp_session(page)
|
||||
sel = lambda: page.evaluate("window.__sel") # noqa: E731
|
||||
ime = lambda: page.evaluate("document.activeElement && document.activeElement.inputMode") # noqa: E731
|
||||
|
||||
def tap(x, y):
|
||||
_touch(cdp, "touchStart", [(x, y)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(450)
|
||||
|
||||
def drag(x0, y0, x1, y1):
|
||||
_touch(cdp, "touchStart", [(x0, y0)])
|
||||
for i in range(1, 7):
|
||||
_touch(cdp, "touchMove", [(x0 + (x1 - x0) * i / 6, y0 + (y1 - y0) * i / 6)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(450)
|
||||
|
||||
# ⑥ 자판 없는 단추 줄(4-3) — 6개 보임 · 🔍 = 찾기 칸 열림
|
||||
n = page.evaluate(
|
||||
"[...document.querySelectorAll('.ss-touch-keys > button')].filter(b => b.getClientRects().length).length"
|
||||
)
|
||||
assert n == 6, n
|
||||
page.evaluate("document.querySelector('.ss-touch-keys > button').click()")
|
||||
page.wait_for_function( # 찾기 칸은 늦게 불러옴(크기 예산)
|
||||
"() => [...document.querySelectorAll('.ss-find__input')].some(e => e.getClientRects().length)",
|
||||
timeout=10000,
|
||||
)
|
||||
page.keyboard.press("Escape")
|
||||
page.evaluate("document.activeElement.blur()")
|
||||
gx, gy, gw, gh = page.evaluate( # 찾기 칸이 자리를 먹어 격자가 옮겨졌을 수 있음
|
||||
"(() => { const g = document.querySelector('.aislo-grid').getBoundingClientRect();"
|
||||
" return [g.left, g.top, g.width, g.height]; })()"
|
||||
)
|
||||
|
||||
a = (gx + 90, gy + 60)
|
||||
tap(*a) # ① 고름
|
||||
first = sel()
|
||||
assert first and ":" not in first["범위"], first
|
||||
assert ime() == "none", ime()
|
||||
|
||||
drag(*a, a[0] + 120, a[1] + 60) # ② 고른 칸에서 끌기 = 범위
|
||||
ranged = sel()
|
||||
assert ":" in ranged["범위"] and ranged["범위"].startswith(first["칸"]), (first, ranged)
|
||||
|
||||
tap(gx + 90, gy + 260) # 다른 칸 고름
|
||||
before = sel()
|
||||
drag(gx + 200, gy + 330, gx + 200, gy + 200) # ③ 안 고른 칸 끌기 = 고름 그대로
|
||||
assert sel() == before, (before, sel())
|
||||
|
||||
b = (gx + 90, gy + 260)
|
||||
_touch(cdp, "touchStart", [b]) # ④ 두 번 톡 = 고침
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(120)
|
||||
_touch(cdp, "touchStart", [b])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(500)
|
||||
assert ime() == "text", ime()
|
||||
page.evaluate("document.activeElement.blur()")
|
||||
|
||||
page.evaluate("document.querySelector('.ss-toolbar__more').click()") # ⑤ ⋯ = 칸 메뉴
|
||||
page.wait_for_timeout(300)
|
||||
assert page.evaluate(
|
||||
"[...document.querySelectorAll('.ui-ctx-menu')].some(m => !m.hidden && m.getClientRects().length)"
|
||||
)
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""손가락 → 마우스 번역(`A00_Common/touch_bridge.ts` · PLAN_기기대응 3-1) — 실제 브라우저 터치.
|
||||
|
||||
sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자에 CDP 터치를 넣어 봄:
|
||||
① 길게 누름 → contextmenu 한 번 · 뒤따르는 click 없음 ② 짧은 톡 → contextmenu 없음
|
||||
③ 두 번 톡 → dblclick 한 번 ④ 두 손가락 벌림 → Ctrl+휠(deltaY < 0) · 오므림 → deltaY > 0
|
||||
⑤ [data-touch-own] 상자는 번역 안 함 ⑥ 마우스 우클릭 · 더블클릭은 그대로(번역이 끼지 않음)
|
||||
⑦ 고름 반경 `pickPx` — 손가락이면 24px 이상 · 마우스면 그대로
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
BASE = "http://localhost:5174"
|
||||
|
||||
|
||||
def _up() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(BASE, timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
SETUP = """
|
||||
() => {
|
||||
window.__log = [];
|
||||
for (const id of ['tb-box', 'tb-own']) {
|
||||
const b = document.createElement('div');
|
||||
b.id = id;
|
||||
Object.assign(b.style, { position: 'fixed', left: id === 'tb-box' ? '20px' : '220px', top: '120px',
|
||||
width: '180px', height: '180px', background: '#eee', zIndex: 99999, touchAction: 'none' });
|
||||
if (id === 'tb-own') b.dataset.touchOwn = 'x';
|
||||
for (const t of ['contextmenu', 'click', 'dblclick', 'wheel'])
|
||||
b.addEventListener(t, (e) => {
|
||||
window.__log.push({ id, t, ctrl: !!e.ctrlKey, dy: e.deltaY || 0, trusted: e.isTrusted });
|
||||
if (t === 'contextmenu' || t === 'wheel') e.preventDefault();
|
||||
}, { passive: false });
|
||||
document.body.append(b);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _touch(cdp, kind, points):
|
||||
cdp.send(
|
||||
"Input.dispatchTouchEvent",
|
||||
{
|
||||
"type": kind,
|
||||
"touchPoints": [{"x": x, "y": y, "id": i} for i, (x, y) in enumerate(points)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _log(page):
|
||||
out = page.evaluate("() => window.__log.splice(0)")
|
||||
return [(e["id"], e["t"]) for e in out], out
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _up(), reason="sub1 화면 서버(5174) 꺼짐")
|
||||
def test_touch_bridge_translates_finger_gestures():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True
|
||||
)
|
||||
page = ctx.new_page()
|
||||
page.goto(f"{BASE}/#/a01-home")
|
||||
page.wait_for_timeout(1500)
|
||||
assert page.evaluate("() => document.documentElement.dataset.touch") == "on"
|
||||
page.evaluate(SETUP)
|
||||
cdp = ctx.new_cdp_session(page)
|
||||
|
||||
# ① 길게 누름
|
||||
_touch(cdp, "touchStart", [(100, 200)])
|
||||
page.wait_for_timeout(700)
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(500)
|
||||
names, _ = _log(page)
|
||||
assert names.count(("tb-box", "contextmenu")) == 1, names
|
||||
assert ("tb-box", "click") not in names, names
|
||||
|
||||
# ② 짧은 톡
|
||||
page.wait_for_timeout(400)
|
||||
_touch(cdp, "touchStart", [(100, 200)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(700)
|
||||
names, _ = _log(page)
|
||||
assert ("tb-box", "contextmenu") not in names, names
|
||||
assert ("tb-box", "click") in names, names
|
||||
|
||||
# ③ 두 번 톡
|
||||
page.wait_for_timeout(400)
|
||||
for _ in range(2):
|
||||
_touch(cdp, "touchStart", [(100, 200)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(80)
|
||||
page.wait_for_timeout(600)
|
||||
names, _ = _log(page)
|
||||
assert names.count(("tb-box", "dblclick")) == 1, names
|
||||
|
||||
# ④ 두 손가락 벌림 · 오므림
|
||||
def pinch(box_x, start, end):
|
||||
_touch(cdp, "touchStart", [(box_x - start, 210), (box_x + start, 210)])
|
||||
steps = 8
|
||||
for i in range(1, steps + 1):
|
||||
d = start + (end - start) * i / steps
|
||||
_touch(cdp, "touchMove", [(box_x - d, 210), (box_x + d, 210)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
page.wait_for_timeout(200)
|
||||
return _log(page)
|
||||
|
||||
names, raw = pinch(110, 20, 80)
|
||||
wheels = [e for e in raw if e["t"] == "wheel" and e["id"] == "tb-box"]
|
||||
assert wheels and all(e["ctrl"] and e["dy"] < 0 for e in wheels), raw
|
||||
names, raw = pinch(110, 80, 20)
|
||||
wheels = [e for e in raw if e["t"] == "wheel" and e["id"] == "tb-box"]
|
||||
assert wheels and all(e["ctrl"] and e["dy"] > 0 for e in wheels), raw
|
||||
|
||||
# ⑤ 자체 처리 상자 — 길게 누름 · 벌림 번역 없음
|
||||
page.wait_for_timeout(400)
|
||||
_touch(cdp, "touchStart", [(300, 200)])
|
||||
page.wait_for_timeout(700)
|
||||
_touch(cdp, "touchEnd", [])
|
||||
names, raw = pinch(310, 20, 70)
|
||||
assert not [e for e in raw if not e["trusted"] and e["id"] == "tb-own"], raw
|
||||
|
||||
# ⑦ 고름 반경(4-4) — 손가락으로 누른 뒤 = 접촉 크기(최소 24) · 마우스로 누른 뒤 = 그대로
|
||||
# 앱이 불러온 그 주소(vite 가 ?t= 를 붙였을 수 있음) — 다른 사본이면 상태가 따로
|
||||
pick = (
|
||||
"async () => (await import(performance.getEntriesByType('resource')"
|
||||
".map((e) => e.name).find((n) => n.includes('/touch_bridge.ts')))).pickPx(4)"
|
||||
)
|
||||
_touch(cdp, "touchStart", [(100, 200)])
|
||||
_touch(cdp, "touchEnd", [])
|
||||
assert page.evaluate(pick) >= 24
|
||||
page.mouse.click(100, 400)
|
||||
assert page.evaluate(pick) == 4
|
||||
page.wait_for_timeout(400)
|
||||
_log(page)
|
||||
|
||||
# ⑥ 마우스 — 우클릭 · 더블클릭은 브라우저 것 하나씩만
|
||||
page.mouse.click(100, 200, button="right")
|
||||
page.mouse.dblclick(100, 200)
|
||||
page.wait_for_timeout(500)
|
||||
_, raw = _log(page)
|
||||
assert all(e["trusted"] for e in raw), json.dumps(raw)
|
||||
browser.close()
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
.ui-sheet__scroll {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 260px);
|
||||
max-height: calc(100dvh - 260px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:where(.ui-ctx-menu) {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
max-height: calc(100vh - 8px);
|
||||
max-height: calc(100dvh - 8px);
|
||||
}
|
||||
|
||||
.ui-ctx-menu {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
--dt-lv5-size: var(--text-caption);
|
||||
width: fit-content; /* 표가 좁으면 틀도 표만큼 */
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 260px);
|
||||
max-height: calc(100dvh - 260px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dt-line);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.ui-general-layout {
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
min-height: calc(100dvh - var(--app-header-h));
|
||||
padding: var(--spacing-40) var(--spacing-24) var(--spacing-48);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
@@ -111,3 +111,9 @@
|
||||
padding: var(--spacing-24) var(--spacing-16) var(--spacing-40);
|
||||
}
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) — 제목 줄이 왼쪽 위 ☰ 자리에 걸칠 때만 비킴(넓은 화면은 가운데 정렬로 이미 비켜 0).
|
||||
(100vw - 100%) / 2 = 제목 줄 왼쪽 끝 · 세로 스크롤 막대 몫 8 을 더 비움 */
|
||||
html.is-bm-route .ui-general-layout__header {
|
||||
padding-left: max(0px, calc(var(--app-burger-inset) + var(--spacing-8) - (100vw - 100%) / 2));
|
||||
}
|
||||
|
||||
@@ -121,11 +121,17 @@ export function attachHoverInfo(root: HTMLElement, text: HoverText, selector?: s
|
||||
};
|
||||
const over = (e: Event): void => enter(e, HOVER_DELAY_MS);
|
||||
const focus = (e: Event): void => enter(e, 0);
|
||||
// 손가락 — hover 가 없으니 톡 하면 바로 뜸(기기대응 2-6 · 다음 누름에 닫힘)
|
||||
const tap = (e: PointerEvent): void => {
|
||||
if (e.pointerType === "touch") enter(e, 0);
|
||||
};
|
||||
root.addEventListener("pointerup", tap);
|
||||
root.addEventListener("mouseover", over);
|
||||
root.addEventListener("mouseout", leave);
|
||||
root.addEventListener("focusin", focus);
|
||||
root.addEventListener("focusout", leave);
|
||||
return () => {
|
||||
root.removeEventListener("pointerup", tap);
|
||||
root.removeEventListener("mouseover", over);
|
||||
root.removeEventListener("mouseout", leave);
|
||||
root.removeEventListener("focusin", focus);
|
||||
|
||||
@@ -100,6 +100,15 @@ export const ui_locales_common = {
|
||||
App_Nav_News: ["소식", "News"],
|
||||
App_Nav_Education: ["교육", "Education"],
|
||||
App_Nav_Support: ["기술지원", "Support"],
|
||||
App_Menu: ["메뉴", "Menu"],
|
||||
App_TouchMode: ["터치 모드", "Touch mode"],
|
||||
App_TouchMode_Auto: ["자동", "Auto"],
|
||||
App_TouchMode_On: ["켬", "On"],
|
||||
App_TouchMode_Off: ["끔", "Off"],
|
||||
App_Touch_LongPressHint: [
|
||||
"그림 위를 길게 누르면 메뉴 · 두 손가락으로 확대",
|
||||
"Long-press the drawing for the menu · pinch to zoom",
|
||||
],
|
||||
App_Footer_Copyright: [
|
||||
"© 2026 임도설계 및 견적 자동화. All rights reserved.",
|
||||
"© 2026 Forest Road Design & Estimation. All rights reserved.",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
gap: var(--spacing-8);
|
||||
width: min(720px, 92vw);
|
||||
max-height: var(--modal-max, 80vh);
|
||||
margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);
|
||||
margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
.ui-workflow-overlay__panel {
|
||||
position: fixed;
|
||||
top: calc(var(--spacing-64) + var(--spacing-16));
|
||||
top: calc(var(--app-header-h) + var(--spacing-16));
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - var(--spacing-64) - var(--spacing-32));
|
||||
max-height: calc(100dvh - var(--app-header-h) - var(--spacing-32));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
/* title 패널: 헤더 아래 전체 높이 도킹 사이드바 (열림: 본문 밀기 / 닫힘: 좌측으로 슬라이드 아웃) */
|
||||
.ui-workflow-overlay__panel--title {
|
||||
top: var(--spacing-64);
|
||||
top: var(--app-header-h);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48)));
|
||||
@@ -303,9 +303,9 @@
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.ui-workflow-overlay__panel--progress {
|
||||
top: calc(var(--spacing-64) + var(--spacing-8));
|
||||
top: calc(var(--app-header-h) + var(--spacing-8));
|
||||
right: var(--spacing-8);
|
||||
max-height: calc(100vh - var(--spacing-64) - var(--spacing-16));
|
||||
max-height: calc(100dvh - var(--app-header-h) - var(--spacing-16));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,3 +523,43 @@
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 기기 대응(PLAN_기기대응 2-3) — 폭 840 미만(data-size compact · medium)만.
|
||||
좌측 패널 = 서랍(본문 위에 겹침 · 덮개를 누르면 닫힘). */
|
||||
.ui-workflow-overlay__scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html:is([data-size="compact"], [data-size="medium"])
|
||||
.ui-workflow-overlay:has(> .ui-workflow-overlay__panel--title:not(.is-collapsed))
|
||||
> .ui-workflow-overlay__scrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--app-header-h) 0 0 0;
|
||||
z-index: calc(var(--z-dropdown) - 1);
|
||||
background: rgba(38, 17, 74, 0.35);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 로그인 뒤 쪽(B · M)은 떠 있는 진행 판 대신 ☰ 서랍 안 진행 단계 묶음(6-2 · 7-1) */
|
||||
html.is-bm-route .ui-workflow-overlay__panel--progress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 접힌 서랍 — 화면 밖 내용은 안 보이게(손가락 · 읽기 프로그램이 밖의 단추를 잡지 않음) · 손잡이만 남김 */
|
||||
html:is([data-size="compact"], [data-size="medium"])
|
||||
.ui-workflow-overlay__panel--title.is-collapsed
|
||||
> :not(.ui-workflow-overlay__handle) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) — 왼쪽 위에 뜬 ☰ 자리만큼 좌측 패널 제목 줄을 비킴 · 손가락 ☰(높이 44)는 제목 줄도 그만큼 높임 */
|
||||
html.is-bm-route .ui-workflow-overlay__panel--title > .ui-workflow-overlay__header {
|
||||
padding-left: max(var(--spacing-16), var(--app-burger-inset));
|
||||
}
|
||||
|
||||
html.is-bm-route[data-touch="on"]
|
||||
.ui-workflow-overlay__panel--title
|
||||
> .ui-workflow-overlay__header {
|
||||
min-height: calc(var(--spacing-8) * 2 + 44px);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { makePanelDraggable } from "./ui_template_overlay_drag";
|
||||
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
|
||||
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { SIZE_BREAKS } from "../A00_Common/device_context";
|
||||
|
||||
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
|
||||
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
|
||||
@@ -99,6 +100,8 @@ const NARROW_START_COLLAPSED_MAX_PX = 560;
|
||||
* 위에 겹쳐 떠서, 펼친 채로 열면 본문이 가려진 화면부터 보게 된다(2026-09-08).
|
||||
*/
|
||||
function readOpenState(key: string, collapseWhenNarrow = false): boolean {
|
||||
// 폭 840 미만 = 서랍(기기대응 2-3) — 저장값과 상관없이 접은 채로 시작
|
||||
if (collapseWhenNarrow && window.innerWidth < SIZE_BREAKS.expanded) return false;
|
||||
const raw = readByKey(key);
|
||||
if (raw !== null) return raw !== "false";
|
||||
if (!collapseWhenNarrow) return true;
|
||||
@@ -258,7 +261,11 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
|
||||
options.onOptionsOpenChange,
|
||||
options.showProjectName === false ? undefined : createProjectNameTag(),
|
||||
);
|
||||
root.append(titlePanel.root);
|
||||
// 좁은 폭 서랍의 덮개 — 누르면 닫힘(보임은 CSS · 폭 840 미만 + 펼침)
|
||||
const scrim = document.createElement("div");
|
||||
scrim.className = "ui-workflow-overlay__scrim";
|
||||
scrim.addEventListener("click", () => titlePanel.setOpen(false));
|
||||
root.append(scrim, titlePanel.root);
|
||||
setTitleOpen = titlePanel.setOpen;
|
||||
}
|
||||
let setProgressOpen = (_isOpen: boolean): void => {};
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
flex: none;
|
||||
align-self: flex-start;
|
||||
/* 높이 · 붙는 자리 = JS(followView) · 처음 한 번 그릴 때까지 */
|
||||
height: calc(100vh - 80px);
|
||||
height: calc(100dvh - 80px);
|
||||
/* 손잡이 자리(24) + 틈 */
|
||||
margin-left: calc(var(--spacing-24) + var(--spacing-8));
|
||||
}
|
||||
@@ -130,3 +130,40 @@
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain; /* 몸 끝에서 메인으로 이어 굴리지 않음(85-5) */
|
||||
}
|
||||
|
||||
/* 기기 대응(PLAN_기기대응 2-3) — 폭 840 미만은 오른쪽 패널을 본문 아래로 쌓음(좁으면 보조 칸은 아래) ·
|
||||
손잡이 줄은 패널 위 가로 · 붙어 따라오기(followView 인라인 높이 · 이동)는 끔 */
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels,
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels.is-fill,
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels.is-collapsed {
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
height: auto !important;
|
||||
margin: var(--spacing-8) 0 0;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels__handles {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels__panel,
|
||||
html:is([data-size="compact"], [data-size="medium"])
|
||||
.ui-side-panels.is-fill
|
||||
> .ui-side-panels__panel {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: auto;
|
||||
height: 60dvh;
|
||||
}
|
||||
|
||||
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels__grip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ui-workflow-layout {
|
||||
position: relative;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
min-height: calc(100dvh - var(--app-header-h));
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
|
||||
.ui-workflow-layout__body {
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
min-height: calc(100dvh - var(--app-header-h));
|
||||
transition: padding-left var(--transition-base);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,24 @@
|
||||
.ui-workflow-layout__main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
min-height: calc(100dvh - var(--app-header-h));
|
||||
overflow: auto;
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
/* ☰ 들여쓰기(7-2) — 본문 첫 줄이 ☰ 자리를 비키려면 본문 왼쪽 끝에서 더 비울 폭.
|
||||
좌측 패널이 열려 본문을 민 때는 0 · 접힘(손잡이 24) · 좁은 폭에서 본문 위에 겹친 때는 남은 만큼.
|
||||
쪽마다 첫 줄(도구 줄 · 탭 · 제목)이 `padding-left: max(제 여백, var(--wf-burger-pad))` 한 줄로 씀 */
|
||||
html.is-bm-route .ui-workflow-layout {
|
||||
--wf-burger-pad: max(0px, calc(var(--app-burger-inset) - var(--spacing-24)));
|
||||
}
|
||||
|
||||
html.is-bm-route .ui-workflow-layout.is-options-open {
|
||||
--wf-burger-pad: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
html.is-bm-route .ui-workflow-layout.is-options-open {
|
||||
--wf-burger-pad: var(--app-burger-inset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ROUTES } from "@config/config_frontend";
|
||||
import { t } from "./ui_template_locale";
|
||||
import { createWorkflowOverlays } from "./ui_template_overlay";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setDrawerSteps } from "../A00_Common/app_shell";
|
||||
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
|
||||
let dashboardRolePromise: Promise<string> | undefined;
|
||||
@@ -198,6 +199,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
||||
});
|
||||
|
||||
root.append(body, overlays.root);
|
||||
// ☰ 서랍 진행 단계 묶음(6-2) — 같은 단계 목록을 서랍이 열릴 때 빌려 감(그리기 · 누름 한 벌)
|
||||
setDrawerSteps(progressContent);
|
||||
const steps = [
|
||||
...progressContent.querySelectorAll<HTMLElement>(
|
||||
".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)",
|
||||
|
||||
Reference in New Issue
Block a user