diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 0266dab62..13aec256f 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -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 { 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 { diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index ab46c241d..f78642155 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -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; diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 72301e3d8..9590e576c 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -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(); + 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); diff --git a/A00_Common/device_context.css b/A00_Common/device_context.css new file mode 100644 index 000000000..85751d560 --- /dev/null +++ b/A00_Common/device_context.css @@ -0,0 +1,98 @@ +/* ============================================================================= + * device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6) + * 모든 규칙은 `` 표시(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)); +} diff --git a/A00_Common/device_context.ts b/A00_Common/device_context.ts new file mode 100644 index 000000000..4ea4e6d42 --- /dev/null +++ b/A00_Common/device_context.ts @@ -0,0 +1,123 @@ +/* ============================================================================= + * device_context.ts + * 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `` 에 지금 상태를 늘 적음. + * + * - 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(); diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 571f02c10..e7ea76d3e 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -9,6 +9,8 @@ * ========================================================================== */ import "./random_uuid_polyfill"; +import "./device_context"; // 맨 앞 — 그리기 전에 기기 표시(기기대응 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"; diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 40ab62717..27ac5da6a 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -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; diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index e27d0402a..3441b644e 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -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], }); diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index b7a23f3a0..7d4adfe17 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -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 }); } diff --git a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts index e19ee044a..67bc68b39 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts @@ -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] }); diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 03c72697e..d4c8eeb13 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -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; + } +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 9e65764ed..da403b2d4 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -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 }; } } diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index e35ecda22..9bff078dd 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -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?.(); diff --git a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts index b34f99b55..92effb8cd 100644 --- a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts @@ -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] }); diff --git a/A00_Common/spreadsheet/spreadsheet_text.ts b/A00_Common/spreadsheet/spreadsheet_text.ts index 0b097515f..f5cad79f0 100644 --- a/A00_Common/spreadsheet/spreadsheet_text.ts +++ b/A00_Common/spreadsheet/spreadsheet_text.ts @@ -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"], diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index 84bd546d5..fc0ffa84f 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -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); +} diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index d213b4a61..d9fccdb72 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -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), diff --git a/A00_Common/spreadsheet/spreadsheet_touch_keys.ts b/A00_Common/spreadsheet/spreadsheet_touch_keys.ts new file mode 100644 index 000000000..056d0b8a8 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_touch_keys.ts @@ -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); +} diff --git a/A00_Common/touch_bridge.ts b/A00_Common/touch_bridge.ts new file mode 100644 index 000000000..6c83aa700 --- /dev/null +++ b/A00_Common/touch_bridge.ts @@ -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(); + +/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */ +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(); +let press: (Pt & { id: number; target: Element; timer: ReturnType }) | 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(); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index f4f62046f..114a72f0e 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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); diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index be437104d..6719bdcd3 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -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%, diff --git a/B03_FileInput/B03_FileInput_UI_TempPicker.ts b/B03_FileInput/B03_FileInput_UI_TempPicker.ts index 52d84a3c2..fd0dcb436 100644 --- a/B03_FileInput/B03_FileInput_UI_TempPicker.ts +++ b/B03_FileInput/B03_FileInput_UI_TempPicker.ts @@ -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"); diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index b6c20751e..d5adca1d9 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -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(); + 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(); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 388ff9eca..9e4ab2d40 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -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). diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 3ac47607d..566e0999e 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index c3f1a57aa..22c3f6b8e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 864188844..12a99a0d6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 0ff9bc61a..1a6c3bc4d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -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)); diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 4403fcdda..35182c856 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -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"; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 51cc3a763..fb0c4208d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -45,7 +45,7 @@ export function createRouteEditChrome(): RouteEditChrome { const overlay = document.createElement("div"); overlay.className = "ui-modal__backdrop b05-routeedit"; overlay.innerHTML = ` -