From 4e0d7d2a4a8f5cffee59e81692f64308badf7161 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 13:34:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(device):=20=EC=8A=A4=ED=94=84=EB=A0=88?= =?UTF-8?q?=EB=93=9C=EC=8B=9C=ED=8A=B8=20=EC=86=90=EA=B0=80=EB=9D=BD=20?= =?UTF-8?q?=E2=80=94=20=ED=86=A1=20=3D=20=EC=B9=B8=20=EA=B3=A0=EB=A6=84(?= =?UTF-8?q?=EC=9E=90=ED=8C=90=20=EC=95=88=20=EB=9C=B8)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=EC=B9=B8=20=C2=B7=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B1=84=EC=9A=B0=EA=B8=B0=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EB=81=8C=EA=B8=B0=20=3D=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=C2=B7=20=EC=B1=84=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EC=95=88=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=EC=B9=B8=20=EB=81=8C=EA=B8=B0=20=3D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B0=80=EA=B8=B0=20=C2=B7=20=EB=91=90=20?= =?UTF-8?q?=EB=B2=88=20=ED=86=A1=20=3D=20=EA=B3=A0=EC=B9=A8(=EC=86=90=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EC=95=88=EC=97=90=EC=84=9C=20=EC=9E=90?= =?UTF-8?q?=ED=8C=90)=20=C2=B7=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=EC=9E=A1?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EB=A6=AC=2020px=20=C2=B7=20=EB=91=90=20?= =?UTF-8?q?=EC=86=90=EA=B0=80=EB=9D=BD=20=3D=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=ED=99=95=EB=8C=80=20(=EA=B8=B0=EA=B8=B0=EB=8C=80=EC=9D=91=204-?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/spreadsheet/spreadsheet_editor.ts | 8 ++ A00_Common/spreadsheet/spreadsheet_grid.css | 13 +++ A00_Common/spreadsheet/spreadsheet_grid.ts | 7 +- A00_Common/spreadsheet/spreadsheet_mouse.ts | 50 ++++++++ .../src/inputController/input-touch.ts | 7 +- resources/tester/test_spreadsheet_touch.py | 108 ++++++++++++++++++ 6 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_spreadsheet_touch.py 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_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/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts b/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts index a7aeb2a51..aa1d2283c 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-touch.ts @@ -31,7 +31,12 @@ export function attachTouch(ctl: InputController): void { 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 }); + 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(); diff --git a/resources/tester/test_spreadsheet_touch.py b/resources/tester/test_spreadsheet_touch.py new file mode 100644 index 000000000..1992e9d79 --- /dev/null +++ b/resources/tester/test_spreadsheet_touch.py @@ -0,0 +1,108 @@ +"""스프레드시트 손가락(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) + + 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()