feat(device): B07 CAD 손가락 — 한 손가락 화면 옮기기 · 두 손가락 확대 · 길게 누름 정밀 십자선(손가락 위 70px) · 고름 반경 = 접촉 크기(최소 24px) · 자판 없는 명령 단추(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅) · user-scalable=no 뺌 · 100vh → 100dvh (기기대응 4-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Aislo 2D Drawing</title>
|
||||
</head>
|
||||
<body class="m-0 p-0 overflow-hidden w-full h-full min-h-screen flex flex-row">
|
||||
|
||||
@@ -75,7 +75,7 @@ body > canvas[data-id="canvas"] {
|
||||
left: var(--cad-panel-width);
|
||||
width: calc(100vw - var(--cad-panel-width));
|
||||
height: calc(
|
||||
100vh -
|
||||
100dvh -
|
||||
var(--cad-title-height) -
|
||||
var(--cad-ribbon-height) -
|
||||
var(--cad-command-height) -
|
||||
@@ -473,7 +473,7 @@ body > canvas[data-id="canvas"] {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager {
|
||||
max-height: calc(100vh - 250px);
|
||||
max-height: calc(100dvh - 250px);
|
||||
padding: 9px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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<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,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()
|
||||
Reference in New Issue
Block a user