diff --git a/B07_DesignDetail/openwebcad/index.html b/B07_DesignDetail/openwebcad/index.html index 4eb332ccc..862eb748a 100644 --- a/B07_DesignDetail/openwebcad/index.html +++ b/B07_DesignDetail/openwebcad/index.html @@ -4,7 +4,7 @@ - + Aislo 2D Drawing diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index f80bac8e1..704bf11e2 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -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; @@ -1100,3 +1100,33 @@ body.cad-file-dragging::after { color: var(--cad-text); font-size: 11px; } + +/* 손가락 기기 명령 단추(기기대응 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); + } +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts b/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts index 2d497173a..9e9a960a8 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts @@ -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() + ); } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 87cbce18c..b3a2e3e5b 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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) { diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts b/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts new file mode 100644 index 000000000..a7aeb2a51 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts @@ -0,0 +1,138 @@ +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'; + +/** + * 손가락 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(); + let mode: Mode = 'idle'; + let start = { x: 0, y: 0 }; + let last = { x: 0, y: 0 }; + let pinchDist = 0; + let timer: ReturnType | 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); +} diff --git a/resources/tester/test_cad_touch.py b/resources/tester/test_cad_touch.py new file mode 100644 index 000000000..8249943bf --- /dev/null +++ b/resources/tester/test_cad_touch.py @@ -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()