Merge branch 'sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-13 17:47:08 +09:00
97 changed files with 2390 additions and 144 deletions
+267 -31
View File
@@ -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 {
+1 -1
View File
@@ -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;
+32
View File
@@ -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);
+98
View File
@@ -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));
}
+123
View File
@@ -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();
+2
View File
@@ -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";
+2
View File
@@ -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;
}
}
+6 -1
View File
@@ -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);
}
+25 -1
View File
@@ -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);
}
+232
View File
@@ -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();
+1 -1
View File
@@ -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 -1
View File
@@ -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%,
+1 -1
View File
@@ -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");
+78 -20
View File
@@ -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).
+1 -1
View File
@@ -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,
+6 -5
View File
@@ -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));
+8 -1
View File
@@ -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;
+17 -2
View File
@@ -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)) {
+6 -4
View File
@@ -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]);
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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);
}
+2
View File
@@ -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);
+2 -2
View File
@@ -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 {
+5
View File
@@ -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;
+1 -1
View File
@@ -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);
+6 -1
View File
@@ -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}`,
+1 -1
View File
@@ -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 [상세] 그림 ────────────────────────────────────────
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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"]
+87
View File
@@ -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()
+353
View File
@@ -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)
+133
View File
@@ -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()
+1 -1
View File
@@ -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(
+126
View File
@@ -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()
+151
View File
@@ -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()
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
+7 -1
View File
@@ -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));
}
+6
View File
@@ -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);
+9
View File
@@ -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.",
+1 -1
View File
@@ -30,7 +30,7 @@
left: 0;
top: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
transform-origin: 0 0;
}
+1 -1
View File
@@ -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);
+45 -5
View File
@@ -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);
}
+8 -1
View File
@@ -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 => {};
+38 -1
View File
@@ -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;
}
+20 -3
View File
@@ -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)",