From 45e60330b80c326a394542cd8e2da807fa82db00 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 28 Sep 2026 11:01:09 +0700 Subject: [PATCH] =?UTF-8?q?highlight-touch:=20=E0=B9=81=E0=B8=81=E0=B9=89?= =?UTF-8?q?=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=9A=E0=B8=B1?= =?UTF-8?q?=E0=B9=8A=E0=B8=81=E0=B8=88=E0=B8=AD=E0=B8=AA=E0=B8=B1=E0=B8=A1?= =?UTF-8?q?=E0=B8=9C=E0=B8=B1=E0=B8=AA=E0=B8=82=E0=B8=AD=E0=B8=87=20useCus?= =?UTF-8?q?tomSelection=20+=20rectsInSweptPath=20=E0=B8=95=E0=B8=B2?= =?UTF-8?q?=E0=B8=A1=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=87=E0=B8=B2=E0=B8=99?= =?UTF-8?q?=20review=2012=20=E0=B8=82=E0=B9=89=E0=B8=AD=20+=20follow-up=20?= =?UTF-8?q?3=20=E0=B8=82=E0=B9=89=E0=B8=AD=20=E2=80=94=20=E0=B8=A5?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=E0=B9=81=E0=B8=99=E0=B8=A7=E0=B8=99=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=9A=E0=B8=99?= =?UTF-8?q?=20tablet=20=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B8=AA?= =?UTF-8?q?=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B8=A1=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B9=8C=E0=B8=81=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=97=E0=B8=B8?= =?UTF-8?q?=E0=B8=81=E0=B8=84=E0=B8=A3=E0=B8=B1=E0=B9=89=E0=B8=87=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A5=E0=B8=B0=20gesture=20=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B8=96=E0=B8=B2=E0=B8=A7=E0=B8=A3=E0=B8=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B2=E0=B8=87session=20=E2=80=94=20(1)=20fingerOffsetFor:?= =?UTF-8?q?=20FINGER=5FOFFSET=5FPX=3D12=20=E0=B8=84=E0=B8=87=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B8=97=E0=B8=B3=20probe=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=20y=E2=88=9212=20=E0=B8=AB=E0=B8=A5=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94?= =?UTF-8?q?=20(=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94=20PD?= =?UTF-8?q?F=20=E0=B8=9A=E0=B8=99=20tablet=20zoom-fit=20=E0=B8=AA=E0=B8=B9?= =?UTF-8?q?=E0=B8=87=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20~14px=20?= =?UTF-8?q?=E2=80=94=20=E0=B8=A7=E0=B8=B1=E0=B8=94=E0=B8=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=81=20viewport=20820px)=20=E0=B9=84=E0=B8=9B=E0=B9=82?= =?UTF-8?q?=E0=B8=94=E0=B8=99=20heading=20=E0=B8=88=E0=B8=99=E0=B8=A5?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=E0=B9=81=E0=B8=99=E0=B8=A7=E0=B8=99=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B9=80=E0=B8=87=E0=B8=B5=E0=B8=A2=E0=B8=9A=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=20=E2=80=94=20offset=20=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20min(12,=20=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=84=E0=B8=A7=E0=B8=B2?= =?UTF-8?q?=E0=B8=A1=E0=B8=AA=E0=B8=B9=E0=B8=87=E0=B8=9A=E0=B8=A3=E0=B8=A3?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B8=94=E2=88=922)=20=E0=B8=A7=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=E0=B8=88=E0=B8=B2=E0=B8=81=20span=20=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20anchor=20=E0=B8=95=E0=B8=AD=E0=B8=99=20poi?= =?UTF-8?q?nterdown=20(=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=20=E2=89=A528px=20=E0=B8=A2=E0=B8=B1=E0=B8=87?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=2012=20=E0=B9=80=E0=B8=97=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1)=20=E0=B9=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B0=E0=B8=96=E0=B9=89=E0=B8=B2=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B8=81=E0=B8=A7=E0=B9=88=E0=B8=B2=2012=20?= =?UTF-8?q?=E0=B8=88=E0=B8=B0=20resolve=20anchor=20=E0=B8=8B=E0=B9=89?= =?UTF-8?q?=E0=B8=B3=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=88=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=AB=E0=B8=B1=E0=B8=81=E0=B8=88?= =?UTF-8?q?=E0=B8=A3=E0=B8=B4=E0=B8=87=20=E2=80=94=20=E0=B8=A2=E0=B8=B7?= =?UTF-8?q?=E0=B8=99=E0=B8=A2=E0=B8=B1=E0=B8=99=E0=B8=94=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B8=A2=20Playwright=20emulate=20iPad=20+=20CDP=20touch=20?= =?UTF-8?q?=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20(=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=E0=B9=81=E0=B8=81=E0=B9=89=209/11=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87=E0=B9=81=E0=B8=81=E0=B9=89?= =?UTF-8?q?=2011/11)=20(2)=20A1=20rangeBetween=20=E0=B8=9D=E0=B8=B1?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=20boundary=20end=20=E0=B9=81=E0=B8=97?= =?UTF-8?q?=E0=B8=99=20start=20=E2=80=94=20anchor/focus=20=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20word=20snap=20=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20range?= =?UTF-8?q?=20=E0=B8=84=E0=B8=A3=E0=B8=AD=E0=B8=9A=E0=B8=84=E0=B8=B3=20?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=AD=E0=B9=88=E0=B8=B2=E0=B8=99?= =?UTF-8?q?=20start=20=E0=B8=97=E0=B8=B4=E0=B9=89=E0=B8=87=E0=B8=84?= =?UTF-8?q?=E0=B8=B3=E0=B8=97=E0=B9=89=E0=B8=B2=E0=B8=A2=20(=E0=B8=A5?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=20alpha=E2=86=92gamma=20=E0=B9=84=E0=B8=94?= =?UTF-8?q?=E0=B9=89=E0=B9=81=E0=B8=84=E0=B9=88=20alpha=20beta)=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A5=E0=B8=B0=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B9=83?= =?UTF-8?q?=E0=B8=99=E0=B8=84=E0=B8=B3=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=E0=B9=84=E0=B8=94=E0=B9=89=20selection=20=E0=B8=A7?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=87=20(3)=20A2=20anchorPathPt(client=20?= =?UTF-8?q?=E0=B8=84=E0=B8=87=E0=B8=97=E0=B8=B5=E0=B9=88)=E2=86=92anchorRe?= =?UTF-8?q?l=20=E0=B8=AA=E0=B8=B1=E0=B8=94=E0=B8=AA=E0=B9=88=E0=B8=A7?= =?UTF-8?q?=E0=B8=99=200=E2=80=931=20=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=20=E0=B9=81=E0=B8=9B=E0=B8=A5?= =?UTF-8?q?=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B1=E0=B8=9A=E0=B8=97=E0=B8=B8?= =?UTF-8?q?=E0=B8=81=E0=B9=80=E0=B8=9F=E0=B8=A3=E0=B8=A1=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=9E=E0=B8=B4=E0=B8=81=E0=B8=B1=E0=B8=94=20client=20?= =?UTF-8?q?=E0=B9=82=E0=B8=81=E0=B8=AB=E0=B8=81=E0=B8=97=E0=B8=B1=E0=B8=99?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B8=97=E0=B8=B5=E0=B9=88=20auto-scroll=20?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=80?= =?UTF-8?q?=E0=B8=99=E0=B8=B7=E0=B9=89=E0=B8=AD=E0=B8=AB=E0=B8=B2=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=87=E0=B8=A5=E0=B8=B2=E0=B8=81=20?= =?UTF-8?q?=E0=B8=88=E0=B8=99=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=E0=B8=95=E0=B9=89=E0=B8=99=E0=B8=97=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B8=AB=E0=B8=B2=E0=B8=A2=E0=B8=88=E0=B8=B2=E0=B8=81=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=87=20preview=20=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B0=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B8=9A=E0=B8=B1=E0=B8=99=E0=B8=97=E0=B8=B6=E0=B8=81=20(?= =?UTF-8?q?=E0=B8=88=E0=B8=B3=E0=B8=A5=E0=B8=AD=E0=B8=87=2026=20=E0=B8=9A?= =?UTF-8?q?=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B9=80=E0=B8=84?= =?UTF-8?q?=E0=B8=A2=E0=B9=84=E0=B8=94=E0=B9=89=2014)=20(4)=20A3=20rectsIn?= =?UTF-8?q?SweptPath=20(utils/coordinates.ts)=20rewrite:=20snap=20?= =?UTF-8?q?=E0=B8=88=E0=B8=B8=E0=B8=94=20start/end=20=E0=B9=80=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=20center=20=E0=B8=9A=E0=B8=A3=E0=B8=A3?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B9=83=E0=B8=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=AA=E0=B8=B8=E0=B8=94=20(snapY)=20=E0=B8=81=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=E0=B8=95=E0=B8=B1=E0=B8=94=E0=B8=AA=E0=B8=B4?= =?UTF-8?q?=E0=B8=99=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=99/=E0=B8=84=E0=B8=99=E0=B8=A5=E0=B8=B0=E0=B8=9A?= =?UTF-8?q?=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=9E=E0=B8=B4=E0=B8=81=E0=B8=B1=E0=B8=94=E0=B8=99=E0=B8=B4?= =?UTF-8?q?=E0=B9=89=E0=B8=A7=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=84=E0=B8=99?= =?UTF-8?q?=E0=B8=A5=E0=B8=B0=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=E0=B9=81=E0=B8=95=E0=B9=88=20dy=20=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=A2=E0=B8=81=E0=B8=A7=E0=B9=88=E0=B8=B2=20lineTo?= =?UTF-8?q?l=20=E0=B9=80=E0=B8=84=E0=B8=A2=E0=B8=96=E0=B8=B9=E0=B8=81?= =?UTF-8?q?=E0=B8=95=E0=B8=B5=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B8=9A?= =?UTF-8?q?=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=88=E0=B8=99=E0=B8=81=E0=B8=A3?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=84=E0=B8=94=E0=B9=89=200=20rect=20(?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B8=97=E0=B9=89=E0=B8=B2=E0=B8=A2?= =?UTF-8?q?=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B8=9A?= =?UTF-8?q?=E0=B8=99=E2=86=92=E0=B8=95=E0=B9=89=E0=B8=99=E0=B8=9A=E0=B8=A3?= =?UTF-8?q?=E0=B8=A3=E0=B8=97=E0=B8=B1=E0=B8=94=E0=B8=96=E0=B8=B1=E0=B8=94?= =?UTF-8?q?=E0=B9=84=E0=B8=9B=E0=B8=A3=E0=B8=B0=E0=B8=A2=E0=B8=B0=E0=B8=AA?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=99)=20=E2=80=94=20=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1=20desktop=20?= =?UTF-8?q?=E0=B9=80=E0=B8=AB=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B4=E0=B8=A1=20=E0=B9=80=E0=B8=84=E0=B8=AA=20tes?= =?UTF-8?q?t=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=203=20=E0=B9=80?= =?UTF-8?q?=E0=B8=84=E0=B8=AA=E0=B8=A2=E0=B8=B1=E0=B8=87=E0=B8=9C=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=99=20(5)=20A4=20range.collapsed=20(=E0=B8=A2?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=88=E0=B8=99=E0=B8=A7=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87)=20=3D=20?= =?UTF-8?q?=E0=B9=80=E0=B8=84=E0=B8=A5=E0=B8=B5=E0=B8=A2=E0=B8=A3=E0=B9=8C?= =?UTF-8?q?=20lastRef=20+=20preview=20=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=20return=20=E2=80=94=20commit=20=E0=B8=95=E0=B8=A3=E0=B8=87=20?= =?UTF-8?q?preview=20=E0=B9=80=E0=B8=AA=E0=B8=A1=E0=B8=AD=20=E0=B9=81?= =?UTF-8?q?=E0=B8=A2=E0=B8=81=E0=B8=88=E0=B8=B2=E0=B8=81=E0=B9=80=E0=B8=84?= =?UTF-8?q?=E0=B8=AA=E0=B8=81=E0=B8=A3=E0=B8=AD=E0=B8=87=E0=B8=A7=E0=B9=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B8=8A=E0=B8=B1=E0=B9=88=E0=B8=A7=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=B2=E0=B8=A7=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=A2?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=E0=B8=84=E0=B8=87=20lastRef=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B9=80=E0=B8=88=E0=B8=95=E0=B8=99=E0=B8=B2?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1=20(6)=20A5=20caretInSpanAt?= =?UTF-8?q?=20=E0=B8=A7=E0=B8=B2=E0=B8=87=20caret=20=E0=B8=95=E0=B8=B2?= =?UTF-8?q?=E0=B8=A1=E0=B8=AA=E0=B8=B1=E0=B8=94=E0=B8=AA=E0=B9=88=E0=B8=A7?= =?UTF-8?q?=E0=B8=99=20x=20=E0=B9=83=E0=B8=99=20span=20=E0=B9=81=E0=B8=97?= =?UTF-8?q?=E0=B8=99=E0=B8=95=E0=B9=89=E0=B8=99=20span=20=E0=B9=80?= =?UTF-8?q?=E0=B8=AA=E0=B8=A1=E0=B8=AD=20(pdf.js=20=E0=B8=A1=E0=B8=B1?= =?UTF-8?q?=E0=B8=81=E0=B8=84=E0=B8=A3=E0=B8=AD=E0=B8=9A=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=E0=B9=83=E0=B8=99=20span=20=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7)=20=E0=B9=83=E0=B8=8A=E0=B9=89?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=99=20=E2=91=A2=20elementsFromPoint=20=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B0=20=E2=91=A3=20nearest=20span=20(7)=20A6=20fromElement?= =?UTF-8?q?=20=E0=B8=9B=E0=B8=8F=E0=B8=B4=E0=B9=80=E0=B8=AA=E0=B8=98=20tex?= =?UTF-8?q?t=20node=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=AD=E0=B8=A2?= =?UTF-8?q?=E0=B8=B9=E0=B9=88=E0=B8=99=E0=B8=AD=E0=B8=81=20.textLayer=20(?= =?UTF-8?q?=E0=B8=81=E0=B8=B1=E0=B8=99=20DFS=20=E0=B8=AB=E0=B8=A5=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B9=84=E0=B8=9B=20text=20node=20=E0=B9=81=E0=B8=A3?= =?UTF-8?q?=E0=B8=81=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2)=20(8)=20A7.1=20auto-scroll=20speed=20cap=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=B0=E0=B8=A2=E0=B8=B0=E0=B9=80=E0=B8=A5=E0=B8=A2=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=9A=E0=B8=97=E0=B8=B5=E0=B9=88=20margin=20/=20A7?= =?UTF-8?q?.2=20=E0=B8=A5=E0=B8=9A=20seenMovePointerIds=20debug=20/=20A7.4?= =?UTF-8?q?=20mouse=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=8B?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A2=E0=B9=80=E0=B8=97=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=E0=B8=B1=E0=B9=89=E0=B8=99=20(hybrid)=20/=20A7.5=20?= =?UTF-8?q?=E0=B8=AA=E0=B9=88=E0=B8=87=20DragState=20=E0=B8=95=E0=B8=A3?= =?UTF-8?q?=E0=B8=87=E0=B9=83=E0=B8=AB=E0=B9=89=20resolveTextCaretDeep=20?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=20spanCache=20=E0=B8=96=E0=B8=B9?= =?UTF-8?q?=E0=B8=81=E0=B9=80=E0=B8=81=E0=B9=87=E0=B8=9A=E0=B8=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20(9)=20B1=20=E0=B8=A3=E0=B8=B0=E0=B8=AB?= =?UTF-8?q?=E0=B8=A7=E0=B9=88=E0=B8=B2=E0=B8=87=E0=B8=A5=E0=B8=B2=E0=B8=81?= =?UTF-8?q?=20retry=20resolver=20=E0=B9=81=E0=B8=84=E0=B9=88=202=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B4=E0=B8=A8=E0=B9=81=E0=B8=A3=E0=B8=81=20(?= =?UTF-8?q?=E0=B8=84=E0=B8=A3=E0=B8=9A=208=20=E0=B8=97=E0=B8=B4=E0=B8=A8?= =?UTF-8?q?=20~6ms/=E0=B9=80=E0=B8=9F=E0=B8=A3=E0=B8=A1=20=E0=B9=80?= =?UTF-8?q?=E0=B8=81=E0=B9=87=E0=B8=9A=E0=B9=84=E0=B8=A7=E0=B9=89=20pointe?= =?UTF-8?q?rdown=20=E2=80=94=20fail=20=E0=B8=A3=E0=B8=B0=E0=B8=AB=E0=B8=A7?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=87=E0=B8=A5=E0=B8=B2=E0=B8=81=E0=B8=A1?= =?UTF-8?q?=E0=B8=B5=E0=B8=97=E0=B8=B2=E0=B8=87=E0=B8=AD=E0=B8=AD=E0=B8=81?= =?UTF-8?q?=E0=B8=84=E0=B8=B7=E0=B8=AD=E0=B8=84=E0=B8=87=20lastFocus)=20(1?= =?UTF-8?q?0)=20B2+2.3=20spanCache=20=E0=B9=80=E0=B8=81=E0=B9=87=E0=B8=9A?= =?UTF-8?q?=20fraction=200=E2=80=931=20=E0=B8=82=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20(=E0=B9=81=E0=B8=9A?= =?UTF-8?q?=E0=B8=9A=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20anchorRel=20=E2=80=94=20=E0=B8=97=E0=B8=99?= =?UTF-8?q?=20re-fit=20=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1=E0=B8=81?= =?UTF-8?q?=E0=B8=A7=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=20gesture=20=E0=B9=80=E0=B8=8A=E0=B9=88=E0=B8=99=20?= =?UTF-8?q?=E0=B8=99=E0=B8=B4=E0=B9=89=E0=B8=A7=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B8=AA=E0=B8=AD=E0=B8=87=20toggle=20sidebar)=20=E0=B8=A7?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=E0=B8=84=E0=B8=A3=E0=B8=B1=E0=B9=89=E0=B8=87?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=95=E0=B9=88?= =?UTF-8?q?=E0=B8=AD=20gesture=20=E0=B9=81=E0=B8=97=E0=B8=99=20getBounding?= =?UTF-8?q?ClientRect=20=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=B1=E0=B9=89=E0=B8=87=20(~2.3ms)=20(11)=20code?= =?UTF-8?q?=20review=202.1:=20pointerup/pointercancel=20=E0=B8=A2=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=A2=E0=B8=88=E0=B8=B2=E0=B8=81=20
=20?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B8=95=E0=B8=B2=E0=B8=82?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=A2=20document=20=E0=B9=81=E0=B8=9A?= =?UTF-8?q?=E0=B8=9A=20capture=20phase=20=E0=B8=9C=E0=B8=B9=E0=B8=81?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B9=81=E0=B8=95=E0=B9=88?= =?UTF-8?q?=20pointerdown=20=E0=B8=88=E0=B8=99=20finish=20=E2=80=94=20Chro?= =?UTF-8?q?mium=20=E0=B8=81=E0=B8=B1=E0=B8=9A=20touch=20=E0=B8=96=E0=B8=AD?= =?UTF-8?q?=E0=B8=94=20pointer=20capture=20=E0=B8=81=E0=B8=A5=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=20drag=20=E0=B9=84=E0=B8=94=E0=B9=89=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20=E0=B8=9E=E0=B8=AD=20capture=20=E0=B8=AB?= =?UTF-8?q?=E0=B8=B2=E0=B8=94=20pointerup=20=E0=B8=A2=E0=B8=B4=E0=B8=87?= =?UTF-8?q?=E0=B8=99=E0=B8=AD=E0=B8=81=20
=20=E0=B8=88=E0=B8=99=20dra?= =?UTF-8?q?gRef=20=E0=B8=84=E0=B9=89=E0=B8=B2=E0=B8=87=20gesture=20?= =?UTF-8?q?=E0=B8=96=E0=B8=B1=E0=B8=94=E0=B9=84=E0=B8=9B=E0=B9=80=E0=B8=87?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=9A=E0=B8=AB=E0=B8=A1=E0=B8=94=20(arm?= =?UTF-8?q?=20=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=E0=B8=95?= =?UTF-8?q?=E0=B8=AD=E0=B8=99=20active=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=9E=E0=B8=AD=20=E2=80=94=20tap=20=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=82=E0=B8=A2=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=E0=B8=81=E0=B9=87=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=E0=B8=95=E0=B8=B2=E0=B8=82=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B8=A3=E0=B8=B1=E0=B8=9A=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B8=B1=E0=B9=89=E0=B8=99=E0=B8=84=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B9=80=E0=B8=AB=E0=B8=A1=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B8=81=E0=B8=B1=E0=B8=99=20=E0=B9=80=E0=B8=88?= =?UTF-8?q?=E0=B8=AD=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=E0=B8=95=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B9=81=E0=B8=81=E0=B9=89=20e2e=209/13)=20(12)=20A7.?= =?UTF-8?q?3=20=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B9=83=E0=B8=8A=E0=B9=89:=20?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=E0=B8=B2=E0=B8=A1=E0=B8=9C=E0=B8=B9=E0=B8=81?= =?UTF-8?q?=20finish=20=E0=B9=80=E0=B8=82=E0=B9=89=E0=B8=B2=20lostpointerc?= =?UTF-8?q?apture=20=E2=80=94=20=E0=B8=81=E0=B8=B1=E0=B8=9A=20touch=20?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=87=20Chromium=20=E0=B8=A2=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=87=20drag=20?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=E0=B9=80=E0=B8=AD=E0=B8=87=20(impli?= =?UTF-8?q?cit=20capture=20=E0=B8=82=E0=B8=AD=E0=B8=87=20span=20+=20re-hit?= =?UTF-8?q?-test)=20=E0=B8=9C=E0=B8=B9=E0=B8=81=E0=B9=81=E0=B8=A5=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=20dragRef=20=E0=B8=96=E0=B8=B9=E0=B8=81=E0=B9=80?= =?UTF-8?q?=E0=B8=84=E0=B8=A5=E0=B8=B5=E0=B8=A2=E0=B8=A3=E0=B9=8C=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=87=E0=B8=97=E0=B8=B2=E0=B8=87=20gestur?= =?UTF-8?q?e=20=E0=B8=95=E0=B8=B2=E0=B8=A2=E0=B8=AB=E0=B8=A1=E0=B8=94=20?= =?UTF-8?q?=E2=80=94=20=E0=B8=A2=E0=B8=B7=E0=B8=99=E0=B8=A2=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20CDP=20touch=20e?= =?UTF-8?q?vent=20log=20=E0=B8=88=E0=B8=A3=E0=B8=B4=E0=B8=87=20(13)=202.2?= =?UTF-8?q?=20=E0=B9=81=E0=B8=81=E0=B9=89=20comment=20fingerOffsetFor=20?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=95=E0=B8=A3=E0=B8=87=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B9=84=E0=B8=81=20(=E0=B9=80=E0=B8=84=E0=B8=AA=20pr?= =?UTF-8?q?obe=20=E0=B9=82=E0=B8=94=E0=B8=99=20heading=20=E2=89=A528px=20?= =?UTF-8?q?=E0=B8=A2=E0=B8=B1=E0=B8=87=E0=B9=83=E0=B8=8A=E0=B9=89=20heuris?= =?UTF-8?q?tic=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1)=20(14)=20tests:=20c?= =?UTF-8?q?oordinates=20+7=20=E0=B9=80=E0=B8=84=E0=B8=AA=20(=E0=B8=A3?= =?UTF-8?q?=E0=B8=A7=E0=B8=A1=202=20=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=20dy=20=E0=B9=80=E0=B8=A5=E0=B9=87=E0=B8=81?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=84=E0=B8=A2=E0=B9=84?= =?UTF-8?q?=E0=B8=94=E0=B9=89=200=20=E0=B9=81=E0=B8=A5=E0=B8=B0=20auto-scr?= =?UTF-8?q?oll=2026=20=E0=B8=9A=E0=B8=A3=E0=B8=A3=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B8=94)=20+=20=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B8=A1=E0=B9=88=20tests/hooks/useCustomSelection-do?= =?UTF-8?q?m.test.ts=207=20=E0=B9=80=E0=B8=84=E0=B8=AA=20snapCaretToWord/r?= =?UTF-8?q?angeBetween=20(export=20=E0=B9=80=E0=B8=9E=E0=B8=B4=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B9=80=E0=B8=9E=E0=B8=B7=E0=B9=88=E0=B8=AD=20test?= =?UTF-8?q?=20=E0=B8=9E=E0=B8=A3=E0=B9=89=E0=B8=AD=E0=B8=A1=20comment)=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20pragma=20@vitest-environ?= =?UTF-8?q?ment=20jsdom=20=E0=B8=A3=E0=B8=A7=E0=B8=A1=E0=B8=84=E0=B8=B3?= =?UTF-8?q?=E0=B9=84=E0=B8=97=E0=B8=A2=E0=B8=81=E0=B8=B1=E0=B8=9A=20Intl.S?= =?UTF-8?q?egmenter=20"=E0=B9=81=E0=B8=A1=E0=B8=A7=E0=B8=81=E0=B8=B4?= =?UTF-8?q?=E0=B8=99=E0=B8=9B=E0=B8=A5=E0=B8=B2"=E2=86=92"=E0=B9=81?= =?UTF-8?q?=E0=B8=A1=E0=B8=A7"=20=E2=80=94=20=E0=B9=80=E0=B8=9E=E0=B8=B4?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20devDependency=20jsdom=20(test-only)=20(15)?= =?UTF-8?q?=20rules/pdf-viewer.md=20sync=20=E0=B8=88=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=9E=E0=B8=A4=E0=B8=95=E0=B8=B4?= =?UTF-8?q?=E0=B8=81=E0=B8=A3=E0=B8=A3=E0=B8=A1=E0=B9=80=E0=B8=9B=E0=B8=A5?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B8=84=E0=B8=A3=E0=B8=9A?= =?UTF-8?q?=20=E2=80=94=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20code=20re?= =?UTF-8?q?view=20=E0=B8=A3=E0=B8=AD=E0=B8=9A=202=20(=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=E0=B8=B5=20bug=20=E0=B8=A3=E0=B8=B0=E0=B8=94?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=81?= =?UTF-8?q?=E0=B8=81=E0=B9=89)=20+=20tsc=20+=20vitest=20157/157=20+=20e2e?= =?UTF-8?q?=20tablet=2012/13=20(=E0=B9=80=E0=B8=84=E0=B8=AA=20F=20fail=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=88?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=E0=B9=80=E0=B8=95=E0=B8=A3=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A1=E0=B9=80=E0=B8=87=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99?= =?UTF-8?q?=E0=B9=84=E0=B8=82=E0=B8=82=E0=B8=AD=E0=B8=87=20script=20?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=87=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B9=83=E0=B8=8A=E0=B9=88=E0=B8=9F=E0=B8=B1=E0=B8=87=E0=B8=81?= =?UTF-8?q?=E0=B9=8C=E0=B8=8A=E0=B8=B1=E0=B8=99=20=E2=80=94=20A3=20?= =?UTF-8?q?=E0=B8=84=E0=B8=A3=E0=B8=AD=E0=B8=9A=E0=B9=82=E0=B8=94=E0=B8=A2?= =?UTF-8?q?=20unit=20tests)=20(=E0=B8=84=E0=B8=B3=E0=B8=82=E0=B8=AD?= =?UTF-8?q?=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A=E0=B9=89=202026-0?= =?UTF-8?q?9-28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/pdf-viewer.md | 2 +- hooks/useCustomSelection.ts | 263 +++++++--- package-lock.json | 537 ++++++++++++++++++++- package.json | 1 + tests/hooks/useCustomSelection-dom.test.ts | 82 ++++ tests/utils/coordinates.test.ts | 64 +++ utils/coordinates.ts | 38 +- 7 files changed, 913 insertions(+), 74 deletions(-) create mode 100644 tests/hooks/useCustomSelection-dom.test.ts diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index d5cc7fb..a0bcf82 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -25,7 +25,7 @@ paths: - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand - Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `
` = `mainRef.current.scrollBy` สวนทางการลาก (ทั้งแกน x/y) + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` บน desktop / custom selection ของ `useCustomSelection` บนจอสัมผัส + ซ่อน native selection) — tool "เลือก" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight -- **จอสัมผัส (iOS/Android) + tool highlight ใช้ custom selection ผ่าน `hooks/useCustomSelection.ts`** (ไม่พึ่ง native selection ของ OS อีก — OS ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release ทำไม่ได้): เปิดเฉพาะอุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง (`enabled = tool highlight + coarse` — **desktop ใช้ native mouse selection + commit ตอน mouseup ตามเดิม ไม่เปลี่ยน**; tool อื่นบนจอสัมผัสก็ native selection + long-press copy ของ OS ตามปกติ) — Pointer Events ชุดเดียวคุม mouse+touch บน `
`: `pointerdown` เก็บ anchor caret (จาก `caretRangeFromPoint` → fallback `caretPositionFromPoint` → ไม่มี = bail เงียบ ๆ; ต้องเป็น text node + เริ่มบน `.textLayer` เท่านั้น) ยังไม่ทำอะไร → เลย threshold **8px** จึง `setPointerCapture` + เริ่ม select จริง (**จับ pointer ตอนเริ่ม select ไม่ใช่ pointerdown** — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/ปุ่ม pin ใช้ไม่ได้; tap ที่ไม่ขยับ = ไม่ capture click ไปถึงเป้าหมายตามเดิม และ click หลังลากจริง retarget ไป main = ไม่หลุดเข้า handlePageClick) — ระหว่างลาก: อ่าน focus caret ที่จุดนิ้ว**เลื่อนขึ้น 12px** (`FINGER_OFFSET_PX` เฉพาะ pointerType touch — นิ้วบังข้อความ), **word snap** ขอบคำทั้ง anchor/focus (`Intl.Segmenter` แยกคำไทยได้ → fallback regex `\S+`, กลางช่องว่าง = ไม่ขยาย), live preview ผ่าน **rAF throttle** (Range → `getClientRects` → `rectsInSweptPath` กรองแนวเส้นทางนิ้ว → `rectToPercent`+`mergeRectsByLine` — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า `customSelPreview ?? liveMark` — **จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−12)**: เก็บ `anchorPathPt` = จุด probe ที่ resolve anchor สำเร็จ และ `pathEnd` = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), **auto-scroll** rAF loop เมื่อนิ้วใกล้ขอบทั้ง 4 ด้านของพื้นที่เอกสาร (rect ของ `
` ใต้ header ไม่ใช่ขอบจอ) 60px — เลื่อน scroll container ของ `
` ทั้งแกนตั้ง/แนวนอน แล้วคำนวณ preview ใหม่ที่จุดนิ้วเดิม — **`pointerup` = commit ทันที**ผ่าน `onCommit(range,start,end)` → `handleCustomSelectionCommit` (ยืม `selDragStart/EndRef` ชั่วคราว + `readTextSelection(pi, { withText: true, range })` ชุดเดียวกับ desktop) → `commitHighlight` — **ไม่มีปุ่มยืนยันลอย/debounce/snapshot ใน ref อีกต่อไป** (selectionchange ของ tool highlight บน coarse ไม่ยิงเพราะไม่มี native selection); ระหว่าง enabled ให้ `
` ได้ class `.pdf-custom-select` (globals.css: `user-select`/`-webkit-user-select`/`-webkit-touch-callout: none` + `touch-action: none` กัน browser ยึด gesture เป็น scroll → pointercancel; restore `user-select:text` ให้ input/textarea/contenteditable ใต้ main) **ถอดทันที**เมื่อ tool เปลี่ยน/ปิด — ผลคือช่วง tool highlight บนจอสัมผัส **ลากที่ไหนก็ไม่เลื่อนหน้า** (gesture เป็นของ selection หมด เลื่อนหน้าต่อได้ด้วย auto-scroll ระหว่างลาก หรือสลับ tool) — เคลียร์ state ครบทุกทาง: pointerup/pointercancel (cancel = ทิ้ง selection ไม่ commit), enabled เปลี่ยน, unmount — ยกเลิก rAF ทั้ง preview/auto-scroll ไม่มี loop ค้าง — **กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด** (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (`resolveTextCaretDeep` — ใช้**เฉพาะตอน pointerdown** กับทั้งจุดชดเชยนิ้ว y−12 และจุดแตะจริง): ① `caretRangeFromPoint`/`caretPositionFromPoint` → startContainer เป็น Element (จุดตก gap/padding ระหว่าง span) ให้ดำดิ่งหา text node ผ่าน `childNodes[startOffset]` + `findFirstTextNode` DFS → ② retry ขยับพิกัด 8 ทิศ (±2/±3px) → ③ `elementsFromPoint` เจาะผ่าน overlay ที่ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ข้อความใต้จุด → ④ span ของหน้านั้นที่**ใกล้สุดแนวตั้ง ≤40px** (`NEAREST_SPAN_MAX_PX` — เกินถือว่าจุดนั้นไม่มีข้อความจริง) — หมดทั้ง 4 ชั้นค่อย bail เป็น tap ธรรมดา; **ระหว่างลาก (ทุก rAF frame) ใช้เฉพาะชั้น ①② ที่ถูก** — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้**คง focus เดิมจาก frame ก่อน** (`dragRef.lastFocus`) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (cache รายชื่อ span ต่อ 1 gesture สำหรับชั้น ④) (2) `lastRef` (payload commit ล่าสุด) **เคลียร์เฉพาะตอน pointerdown, finish และเมื่อหน้า anchor ถูก virtualization unmount กลางลาก (`anchorPageEl.isConnected === false` — rects ของ detached nodes ว่างหมด ต้องไม่ commit ของเก่าเงียบ ๆ) เท่านั้น** — frame ที่ `rectsInSweptPath` กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview เพื่อให้ pointerup ยัง commit selection ล่าสุดที่ไม่ว่างได้ (3) ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า → หา `[data-page]` ไม่ได้) **ห้าม bail** — `clampToAnchorPage`: clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) ด้วย text node แรก/สุดท้ายของ textLayer หน้า anchor (`anchorPageEl` เก็บตั้งแต่ pointerdown, boundary cache ต่อ gesture ใน `clampBounds` — ห้ามเดิน TreeWalker ใหม่ทุก rAF frame) — selection จบที่ขอบหน้า anchor แทนการหายทั้งเส้น +- **จอสัมผัส (iOS/Android) + tool highlight ใช้ custom selection ผ่าน `hooks/useCustomSelection.ts`** (ไม่พึ่ง native selection ของ OS อีก — OS ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release ทำไม่ได้): เปิดเฉพาะอุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง (`enabled = tool highlight + coarse` — **desktop ใช้ native mouse selection + commit ตอน mouseup ตามเดิม ไม่เปลี่ยน**; tool อื่นบนจอสัมผัสก็ native selection + long-press copy ของ OS ตามปกติ) — Pointer Events ชุดเดียวคุม mouse+touch บน `
`: `pointerdown` เก็บ anchor caret (จาก `caretRangeFromPoint` → fallback `caretPositionFromPoint` → ไม่มี = bail เงียบ ๆ; ต้องเป็น text node + เริ่มบน `.textLayer` เท่านั้น) ยังไม่ทำอะไร → เลย threshold **8px** จึง `setPointerCapture` + เริ่ม select จริง (**จับ pointer ตอนเริ่ม select ไม่ใช่ pointerdown** — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/ปุ่ม pin ใช้ไม่ได้; tap ที่ไม่ขยับ = ไม่ capture click ไปถึงเป้าหมายตามเดิม และ click หลังลากจริง retarget ไป main = ไม่หลุดเข้า handlePageClick) — ระหว่างลาก: อ่าน focus caret ที่จุดนิ้ว**เลื่อนขึ้นเท่า offset ของ gesture** (เฉพาะ pointerType touch — นิ้วบังข้อความ; offset = `min(FINGER_OFFSET_PX=12, ครึ่งความสูงบรรทัด−2)` คำนวณครั้งเดียวตอน pointerdown จาก span ของ anchor ผ่าน `fingerOffsetFor` — บรรทัด PDF บน tablet zoom-fit สูงแค่ ~14px การหักตายตัว 12px ทำ probe หลุดขอบบนบรรทัดไปโดน heading จนลากแนวนอนแล้วเงียบ, รายงาน 2026-09-28; บรรทัดใหญ่ ≥28px ยังได้ 12 เท่าเดิม), **word snap** ขอบคำทั้ง anchor/focus (`Intl.Segmenter` แยกคำไทยได้ → fallback regex `\S+`, กลางช่องว่าง = ไม่ขยาย; หลัง snap แล้ว `rangeBetween` ประกบ anchor↔focus โดยฝั่งหลังใช้ boundary **end** ของ range ครอบคำ — ใช้ start จะทิ้งคำท้าย "ลาก alpha→gamma ได้แค่ alpha beta" และลากในคำเดียวได้ selection ว่าง, A1 2026-09-28), live preview ผ่าน **rAF throttle** (Range → `getClientRects` → `rectsInSweptPath` กรองแนวเส้นทางนิ้ว — จุด start/end ถูก **snap เข้า center บรรทัดใกล้สุดก่อนตัดสิน** กันพิกัดนิ้วที่คนละบรรทัดแต่ dy น้อยถูกตีเป็นบรรทัดเดียวจนกรองได้ 0 rect, A3 2026-09-28 — ใช้ร่วมกับ desktop เหมือนเดิม มี unit tests ที่ tests/utils/coordinates.test.ts) → `rectToPercent`+`mergeRectsByLine` — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า `customSelPreview ?? liveMark` — **จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−offset ของ gesture)**: จุดเริ่มเก็บเป็น**สัดส่วน 0–1 ของหน้า anchor** (`anchorRel` — ตำแหน่ง probe ที่ resolve anchor สำเร็จ) แล้วแปลงกลับเป็นพิกัดจอ**ใหม่ทุกเฟรม**ด้วย rect ปัจจุบันของหน้า (พิกัด client คงที่โกหกทันทีที่ auto-scroll เลื่อนเนื้อหากลางลาก จนบรรทัดต้นทางหายจากทั้ง preview และค่าที่บันทึก, A2 2026-09-28) ส่วน `pathEnd` = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาดเท่า offset จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), **auto-scroll** rAF loop เมื่อนิ้วใกล้ขอบทั้ง 4 ด้านของพื้นที่เอกสาร (rect ของ `
` ใต้ header ไม่ใช่ขอบจอ) 60px — เลื่อน scroll container ของ `
` ทั้งแกนตั้ง/แนวนอน แล้วคำนวณ preview ใหม่ที่จุดนิ้วเดิม — **`pointerup` = commit ทันที**ผ่าน `onCommit(range,start,end)` → `handleCustomSelectionCommit` (ยืม `selDragStart/EndRef` ชั่วคราว + `readTextSelection(pi, { withText: true, range })` ชุดเดียวกับ desktop) → `commitHighlight` — **ไม่มีปุ่มยืนยันลอย/debounce/snapshot ใน ref อีกต่อไป** (selectionchange ของ tool highlight บน coarse ไม่ยิงเพราะไม่มี native selection); ระหว่าง enabled ให้ `
` ได้ class `.pdf-custom-select` (globals.css: `user-select`/`-webkit-user-select`/`-webkit-touch-callout: none` + `touch-action: none` กัน browser ยึด gesture เป็น scroll → pointercancel; restore `user-select:text` ให้ input/textarea/contenteditable ใต้ main) **ถอดทันที**เมื่อ tool เปลี่ยน/ปิด — ผลคือช่วง tool highlight บนจอสัมผัส **ลากที่ไหนก็ไม่เลื่อนหน้า** (gesture เป็นของ selection หมด เลื่อนหน้าต่อได้ด้วย auto-scroll ระหว่างลาก หรือสลับ tool) — เคลียร์ state ครบทุกทาง: pointerup/pointercancel (cancel = ทิ้ง selection ไม่ commit), enabled เปลี่ยน, unmount — ยกเลิก rAF ทั้ง preview/auto-scroll ไม่มี loop ค้าง — **pointerup/pointercancel ผูกเป็นตาข่ายระดับ document แบบ capture phase** (`armDocUpListeners` ตั้งแต่ pointerdown ที่เริ่ม gesture จนถึง finish — รวม tap ที่ไม่ activate ไม่งั้น dragRef ค้าง) แทนการผูกที่ `
`: Chromium กับ touch ถอด pointer capture กลาง drag ได้เอง พอ capture หาด pointerup ยิงที่ element ใต้นิ้วจริงซึ่งอาจอยู่นอก `
` (code review 2.1 2026-09-28) — และ**ห้ามผูก finish เข้า `lostpointercapture`**: กับ touch ของ Chromium event นี้ยิงกลาง drag ได้เอง (re-hit-test ของ touch pointer ที่ implicit capture อยู่ที่ span) ผูกแล้ว dragRef ถูกเคลียร์กลางทางจน gesture ทุกตัวเงียบหมด — ยืนยันด้วย Playwright + CDP touch 2026-09-28 (ข้อ A7.3 ของ review ไม่ใช้); ชั้น ③④ ของ resolver วาง caret ตาม**สัดส่วน x ใน span** (`caretInSpanAt`) ไม่ใช่ต้น span เสมอ (pdf.js มักครอบทั้งบรรทัดใน span เดียว — วางต้น span จน anchor เด้งไปต้นบรรทัด, A5) — **กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด** (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (`resolveTextCaretDeep` — ใช้**เฉพาะตอน pointerdown** กับทั้งจุดชดเชยนิ้ว y−12 และจุดแตะจริง; หลัง resolve สำเร็จครั้งแรกถ้า `fingerOffsetFor` ได้ offset น้อยกว่า 12 จะ resolve anchor ซ้ำที่ y−offset จริง — กัน anchor ค้างอยู่บรรทัดที่ probe −12 พลาดไปโดน): ① `caretRangeFromPoint`/`caretPositionFromPoint` → startContainer เป็น Element (จุดตก gap/padding ระหว่าง span) ให้ดำดิ่งหา text node ผ่าน `childNodes[startOffset]` + `findFirstTextNode` DFS → ② retry ขยับพิกัด 8 ทิศ (±2/±3px) → ③ `elementsFromPoint` เจาะผ่าน overlay ที่ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ข้อความใต้จุด → ④ span ของหน้านั้นที่**ใกล้สุดแนวตั้ง ≤40px** (`NEAREST_SPAN_MAX_PX` — เกินถือว่าจุดนั้นไม่มีข้อความจริง) — หมดทั้ง 4 ชั้นค่อย bail เป็น tap ธรรมดา; **ระหว่างลาก (ทุก rAF frame) ใช้เฉพาะชั้น ①② ที่ถูก และ retry แค่ 2 ทิศแรก** (`resolveTextCaret(x, y, 2)` — retry ครบ 8 ทิศเปลือง ~6ms/เฟรม เอาไว้เฉพาะ pointerdown ที่ anchor ต้องเจอ, B1 2026-09-28) — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้**คง focus เดิมจาก frame ก่อน** (`dragRef.lastFocus`) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (ชั้น ④ ใช้ cache พิกัด span **สัมพัทธ์ของหน้า** วัดครั้งเดียวต่อ gesture — `getBoundingClientRect` ทั้งหน้าแพง ~2.3ms/ครั้ง, บวก rect ปัจจุบันของหน้าคืนตอนใช้จึงทน auto-scroll, B2; ต้องส่ง `d` ตรง ๆ ให้ resolver ไม่ใช่ object ห่อใหม่ — ไม่งั้น cache ไม่ถูกเก็บกลับ) (2) `lastRef` (payload commit ล่าสุด) **เคลียร์เฉพาะตอน pointerdown, finish, เมื่อ range กลายเป็น collapsed (ผู้ใช้ย่อ selection จนว่างจริง — ปล่อยนิ้วต้องไม่ commit ให้ตรงกับ preview, A4 2026-09-28) และเมื่อหน้า anchor ถูก virtualization unmount กลางลาก (`anchorPageEl.isConnected === false` — rects ของ detached nodes ว่างหมด ต้องไม่ commit ของเก่าเงียบ ๆ) เท่านั้น** — frame ที่ `rectsInSweptPath` กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview เพื่อให้ pointerup ยัง commit selection ล่าสุดที่ไม่ว่างได้ (3) ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า → หา `[data-page]` ไม่ได้) **ห้าม bail** — `clampToAnchorPage`: clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) ด้วย text node แรก/สุดท้ายของ textLayer หน้า anchor (`anchorPageEl` เก็บตั้งแต่ pointerdown, boundary cache ต่อ gesture ใน `clampBounds` — ห้ามเดิน TreeWalker ใหม่ทุก rAF frame) — selection จบที่ขอบหน้า anchor แทนการหายทั้งเส้น - **tap/คลิกที่ใช้ "ปิดเมนู" ต้องไม่สร้างรายการ**: outside-click ปิดเมนูที่ `mousedown` ก่อน `click` ยิงถึง page wrapper ทำให้ tool comment/textbox/stamp วางรายการจาก tap ปิดเมนูโดยไม่ตั้งใจ — `handlePageClick` เช็ค `menuClosedAtRef` (เขียนทุก path ปิดเมนูผ่าน `markMenuClosed`: outside-click/Escape/switchTool/เลื่อนแถบ/resize; ยกเว้นคีย์ B ปิดเมนูเพื่อเปิด popup bookmark — คีย์บอร์ดไม่มี click หลงเหลือให้กลืน และยกเว้นการเลือกแถวเดี่ยวในเมนู (รูปแบบมาร์ก/ชนิดปากกา/ชนิดรูปทรง/preset ตรายาง — UX audit 2026-09-22) ที่ปิดเมนูด้วย setState ตรง ๆ ไม่ผ่าน markMenuClosed: tap นั้นอยู่บนเมนูเองไม่มี click หลงเหลือถึงหน้า และผู้ใช้เลือกค่าแล้วตั้งใจแตะหน้าวางต่อทันที — ส่วนแถวปรับค่า สี/ความหนา/ความทึบ/ขนาด ยังคงเมนูเปิดให้ปรับต่อ) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch) - **popup บนหน้าเปิดได้ทีละชุด ห้ามค้างเปิดซ้อนกัน** (bug "กดแก้ไขแล้วกดปุ่ม tool popup ไม่ปิด/ซ้อนกัน"): ปุ่ม dropdown ทั้ง 5 **เรียก `switchTool` เสมอแม้เป็น tool เดิม** (จำ intent เปิด/พับเมนูไว้ก่อนเรียก เพราะ `switchTool` ปิดเมนูทุกตัว) เนื่องจาก `switchTool` เป็นที่เดียวที่เคลียร์ popup บนหน้าทั้งหมด — composer/pin popover/แผง selection/editing comment·textbox + popup bookmark (`setBookmarkComposer(null)`); **สร้างรายการแล้ว auto-select (`AUTO_SELECT_TYPES`) ก็ปิดเมนู+popup bookmark เช่นกัน** — บน iOS การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนูเอง (Android/desktop ปิดตั้งแต่เริ่มกดแล้ว) ไม่ปิดตรงนี้ = เมนูค้างเปิดซ้อนแผงที่โผล่หลังสร้าง; คลิกหน้าว่าง (`handlePageClick`) ปิด popover ของ pin + popup bookmark; เลือกรายการอื่น (`handleSelectAnnotation`/`goToAnnotation`) ปิด popover เดิม; popover ของ COMMENT เปิดอยู่ (`openPinId === selectedId`) = **ไม่ render กรอบ selection/แผงเครื่องมือของตัวนั้น** — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม `selectedId` ไฮไลต์เหมือนเดิม) - **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `