feat(device): 스프레드시트 손가락 — 톡 = 칸 고름(자판 안 뜸) · 고른 칸 · 머리 · 채우기 손잡이 끌기 = 범위 · 채우기 · 안 고른 칸 끌기 = 화면 밀기 · 두 번 톡 = 고침(손 동작 안에서 자판) · 손잡이 잡는 자리 20px · 두 손가락 = 격자 확대 (기기대응 4-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-13 13:34:50 +09:00
co-authored by Claude Opus 5.5
parent be3ae14b1b
commit 4e0d7d2a4a
6 changed files with 191 additions and 2 deletions
@@ -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 });
}
@@ -263,3 +263,16 @@
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden;
}
/* 손가락(기기대응 4-2) — 채우기 손잡이를 키움 · 격자는 한 손가락 밀기만 브라우저에(두 손가락 = 격자 확대) */
html[data-touch="on"] .aislo-grid__fill-handle {
width: 14px;
height: 14px;
margin: -4px 0 0 -4px;
}
@media (any-pointer: coarse) {
.aislo-grid {
touch-action: pan-x pan-y;
}
}
+6 -1
View File
@@ -553,7 +553,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
if (handleAt) {
const screen = screenBoxOf(handleAt.r, handleAt.c);
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
// 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 4-2)
const grip = document.documentElement.dataset.touch === "on" ? 20 : 5;
if (
Math.abs(x - (screen.x + screen.w)) <= grip &&
Math.abs(y - (screen.y + screen.h)) <= grip
) {
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
}
}
@@ -255,11 +255,61 @@ export function attachMouse(
};
const onLeave = (): void => hooks.hover?.(null);
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
let touchDrag = false;
let lastTap = 0;
const asMouse = (t: Touch, type: string): MouseEvent =>
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
const onTouchStart = (e: TouchEvent): void => {
if (e.touches.length !== 1 || e.target === ed.ta) return;
const t = e.touches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const grab =
hit &&
(hit.kind === "fillHandle" ||
hit.kind === "rowHead" ||
hit.kind === "colHead" ||
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
if (!grab) return;
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
touchDrag = true;
onDown(asMouse(t, "mousedown"));
};
const onTouchMove = (e: TouchEvent): void => {
if (!touchDrag) return;
e.preventDefault();
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
};
const onTouchEnd = (e: TouchEvent): void => {
if (touchDrag) {
touchDrag = false;
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
}
if (e.touches.length || e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const now = Date.now();
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
lastTap = 0;
ed.begin();
return;
}
lastTap = now;
};
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
root.addEventListener("touchstart", onTouchStart, { passive: false });
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("touchend", onTouchEnd);
return () => {
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("touchend", onTouchEnd);
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
@@ -31,7 +31,12 @@ export function attachTouch(ctl: InputController): void {
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 });
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();
+108
View File
@@ -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()