@@ -62,6 +62,10 @@ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray<readonly [number, number]
6262 [ 0 , - COMMENT_THREAD_HIT_TOLERANCE_PX ] ,
6363 [ 0 , COMMENT_THREAD_HIT_TOLERANCE_PX ] ,
6464] ;
65+ // Boundary clicks are intentionally forgiving so near-edge clicks can place the
66+ // caret before/after an inline SDT instead of selecting it.
67+ const INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX = 12 ;
68+ const INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX = 4 ;
6569
6670const clamp = ( value : number , min : number , max : number ) : number => Math . max ( min , Math . min ( max , value ) ) ;
6771
@@ -1217,10 +1221,18 @@ export class EditorInputManager {
12171221
12181222 // Track click depth for multi-click
12191223 const clickDepth = this . #registerPointerClick( event ) ;
1224+ const hitPos = this . #normalizeInlineSdtBoundaryHitPosition(
1225+ target ,
1226+ event . clientX ,
1227+ event . clientY ,
1228+ doc ,
1229+ hit . pos ,
1230+ clickDepth ,
1231+ ) ;
12201232
12211233 // Set up drag selection state
12221234 if ( clickDepth === 1 ) {
1223- this . #dragAnchor = hit . pos ;
1235+ this . #dragAnchor = hitPos ;
12241236 this . #dragAnchorPageIndex = hit . pageIndex ;
12251237 this . #pendingMarginClick = this . #callbacks. computePendingMarginClick ?.( event . pointerId , x , y ) ?? null ;
12261238
@@ -1290,17 +1302,35 @@ export class EditorInputManager {
12901302 if ( ! handledByDepth ) {
12911303 try {
12921304 // SD-1584: clicking inside a block SDT selects the node (NodeSelection).
1293- const sdtBlock = clickDepth === 1 ? this . #findStructuredContentBlockAtPos( doc , hit . pos ) : null ;
1305+ const sdtBlock = clickDepth === 1 ? this . #findStructuredContentBlockAtPos( doc , hitPos ) : null ;
12941306 let nextSelection : Selection ;
1307+ let inlineSdtBoundaryPos : number | null = null ;
1308+ let inlineSdtBoundaryDirection : 'before' | 'after' | null = null ;
12951309 if ( sdtBlock ) {
12961310 nextSelection = NodeSelection . create ( doc , sdtBlock . pos ) ;
12971311 } else {
1298- nextSelection = TextSelection . create ( doc , hit . pos ) ;
1312+ const inlineSdt = clickDepth === 1 ? this . #findStructuredContentInlineAtPos( doc , hitPos ) : null ;
1313+ if ( inlineSdt && hitPos >= inlineSdt . end ) {
1314+ const afterInlineSdt = inlineSdt . pos + inlineSdt . node . nodeSize ;
1315+ inlineSdtBoundaryPos = afterInlineSdt ;
1316+ inlineSdtBoundaryDirection = 'after' ;
1317+ nextSelection = TextSelection . create ( doc , afterInlineSdt ) ;
1318+ } else if ( inlineSdt && hitPos <= inlineSdt . start ) {
1319+ inlineSdtBoundaryPos = inlineSdt . pos ;
1320+ inlineSdtBoundaryDirection = 'before' ;
1321+ nextSelection = TextSelection . create ( doc , inlineSdt . pos ) ;
1322+ } else {
1323+ nextSelection = TextSelection . create ( doc , hitPos ) ;
1324+ }
12991325 if ( ! nextSelection . $from . parent . inlineContent ) {
1300- nextSelection = Selection . near ( doc . resolve ( hit . pos ) , 1 ) ;
1326+ nextSelection = Selection . near ( doc . resolve ( hitPos ) , 1 ) ;
13011327 }
13021328 }
1303- const tr = editor . state . tr . setSelection ( nextSelection ) ;
1329+ let tr = editor . state . tr . setSelection ( nextSelection ) ;
1330+ if ( inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection ) {
1331+ tr = this . #ensureEditableSlotAtInlineSdtBoundary( tr , inlineSdtBoundaryPos , inlineSdtBoundaryDirection ) ;
1332+ nextSelection = tr . selection ;
1333+ }
13041334 // Preserve stored marks (e.g., formatting selected from toolbar before clicking)
13051335 if ( nextSelection instanceof TextSelection && nextSelection . empty && editor . state . storedMarks ) {
13061336 tr . setStoredMarks ( editor . state . storedMarks ) ;
@@ -1314,6 +1344,98 @@ export class EditorInputManager {
13141344 this . #callbacks. scheduleSelectionUpdate ?.( ) ;
13151345 }
13161346
1347+ #normalizeInlineSdtBoundaryHitPosition(
1348+ target : HTMLElement ,
1349+ clientX : number ,
1350+ clientY : number ,
1351+ doc : ProseMirrorNode ,
1352+ fallbackPos : number ,
1353+ clickDepth : number ,
1354+ ) : number {
1355+ if ( clickDepth !== 1 ) return fallbackPos ;
1356+
1357+ const line =
1358+ target . closest ( `.${ DOM_CLASS_NAMES . LINE } ` ) ??
1359+ ( typeof document . elementsFromPoint === 'function'
1360+ ? ( document
1361+ . elementsFromPoint ( clientX , clientY )
1362+ . find ( ( element ) => element instanceof HTMLElement && element . closest ( `.${ DOM_CLASS_NAMES . LINE } ` ) )
1363+ ?. closest ( `.${ DOM_CLASS_NAMES . LINE } ` ) as HTMLElement | null )
1364+ : null ) ;
1365+ if ( ! line ) return fallbackPos ;
1366+
1367+ const wrappers = Array . from ( line . querySelectorAll < HTMLElement > ( `.${ DOM_CLASS_NAMES . INLINE_SDT_WRAPPER } ` ) ) ;
1368+ const wrapper = wrappers . find ( ( candidate ) => {
1369+ const rect = candidate . getBoundingClientRect ( ) ;
1370+ const verticallyAligned = clientY >= rect . top - 2 && clientY <= rect . bottom + 2 ;
1371+ if ( ! verticallyAligned ) return false ;
1372+
1373+ const nearLeftEdge =
1374+ clientX >= rect . left - INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX &&
1375+ clientX <= rect . left + INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX ;
1376+ const nearRightEdge =
1377+ clientX >= rect . right - INLINE_SDT_BOUNDARY_INSIDE_SLOP_PX &&
1378+ clientX <= rect . right + INLINE_SDT_BOUNDARY_OUTSIDE_SLOP_PX ;
1379+ return nearLeftEdge || nearRightEdge ;
1380+ } ) ;
1381+ if ( ! wrapper ) return fallbackPos ;
1382+
1383+ const rect = wrapper . getBoundingClientRect ( ) ;
1384+ // Treat clicks near left edge as "before SDT", and right half as "after SDT" intent.
1385+ const leftSideThreshold = rect . left + rect . width * 0.2 ;
1386+ const rightSideThreshold = rect . left + rect . width * 0.5 ;
1387+ if ( clientX <= leftSideThreshold ) {
1388+ const pmStartRaw = wrapper . dataset . pmStart ;
1389+ const pmStart = pmStartRaw != null ? Number ( pmStartRaw ) : NaN ;
1390+ if ( ! Number . isFinite ( pmStart ) ) return fallbackPos ;
1391+ return Math . max ( 0 , Math . min ( pmStart , doc . content . size ) ) ;
1392+ }
1393+ if ( clientX < rightSideThreshold ) return fallbackPos ;
1394+
1395+ const pmEndRaw = wrapper . dataset . pmEnd ;
1396+ const pmEnd = pmEndRaw != null ? Number ( pmEndRaw ) : NaN ;
1397+ if ( ! Number . isFinite ( pmEnd ) ) return fallbackPos ;
1398+ return Math . max ( 0 , Math . min ( pmEnd + 1 , doc . content . size ) ) ;
1399+ }
1400+
1401+ #ensureEditableSlotAtInlineSdtBoundary<
1402+ T extends {
1403+ doc : ProseMirrorNode ;
1404+ insertText : ( text : string , from ?: number , to ?: number ) => unknown ;
1405+ setSelection : ( selection : Selection ) => unknown ;
1406+ selection : Selection ;
1407+ } ,
1408+ > ( tr : T , pos : number , direction : 'before' | 'after' ) : T {
1409+ const clampedPos = Math . max ( 0 , Math . min ( pos , tr . doc . content . size ) ) ;
1410+ const needsEditableSlot = ( node : ProseMirrorNode | null | undefined , side : 'before' | 'after' ) =>
1411+ ! node ||
1412+ node . type ?. name === 'hardBreak' ||
1413+ node . type ?. name === 'lineBreak' ||
1414+ node . type ?. name === 'structuredContent' ||
1415+ ( node . type ?. name === 'run' && ! ( side === 'before' ? node . lastChild ?. isText : node . firstChild ?. isText ) ) ;
1416+
1417+ if ( direction === 'before' ) {
1418+ const $pos = tr . doc . resolve ( clampedPos ) ;
1419+ const nodeBefore = $pos . nodeBefore ;
1420+ const shouldInsertBefore = needsEditableSlot ( nodeBefore , 'before' ) ;
1421+
1422+ if ( ! shouldInsertBefore ) return tr ;
1423+
1424+ tr . insertText ( '\u200B' , clampedPos ) ;
1425+ tr . setSelection ( TextSelection . create ( tr . doc , clampedPos + 1 ) ) ;
1426+ return tr ;
1427+ }
1428+
1429+ const nodeAfter = tr . doc . nodeAt ( clampedPos ) ;
1430+ const shouldInsertAfter = needsEditableSlot ( nodeAfter , 'after' ) ;
1431+
1432+ if ( ! shouldInsertAfter ) return tr ;
1433+
1434+ tr . insertText ( '\u200B' , clampedPos ) ;
1435+ tr . setSelection ( TextSelection . create ( tr . doc , clampedPos + 1 ) ) ;
1436+ return tr ;
1437+ }
1438+
13171439 #handlePointerMove( event : PointerEvent ) : void {
13181440 if ( ! this . #deps) return ;
13191441
0 commit comments