@ash-qw/dsh-theme-prts 2.5.5 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "2.5.5",
3
+ "version": "2.6.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web and the official desktop client",
@@ -69,13 +69,15 @@
69
69
  },
70
70
  "engines": {
71
71
  "node": ">=20.9.0",
72
- "dsh": "0.2.1-rc.2 || 0.2.1-rc.3"
72
+ "dsh": "0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.5"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@playwright/test": "1.55.1",
76
76
  "esbuild": "^0.28.2",
77
+ "gsap": "3.14.2",
77
78
  "jsdom": "24.1.3",
78
- "markdown-it": "^14.3.3"
79
+ "markdown-it": "^14.3.3",
80
+ "mouse-follower": "1.2.1"
79
81
  },
80
82
  "dependencies": {
81
83
  "@deepseek-ai/schemastery": "~3.18.4",
@@ -9,11 +9,19 @@ export function mountAvatarCrop({panel,window,load,onSave}){
9
9
  <div data-crop-tools><label>缩放 <input type="range" min="1" max="4" step=".01" value="1" aria-label="头像裁剪缩放"></label><button type="button" data-crop-center>重置裁剪</button></div>
10
10
  <p role="status" data-prts-avatar-crop-status></p>
11
11
  <footer><button type="button" data-crop-reset>恢复默认</button><span></span><button type="button" data-crop-cancel>取消</button><button type="button" data-crop-save>保存头像</button></footer>`
12
+ const confirmation=panel.ownerDocument.createElement('dialog')
13
+ confirmation.className='prts-page-confirm'
14
+ confirmation.setAttribute('aria-labelledby','prts-avatar-reset-title')
15
+ confirmation.innerHTML='<strong id="prts-avatar-reset-title">恢复默认头像?</strong><p>当前头像图片及裁剪设置将被移除。</p><footer><button type="button" data-keep>保留头像</button><button type="button" data-confirm>恢复默认</button></footer>'
16
+ dialog.append(confirmation)
17
+ confirmation.querySelector('[data-keep]').onclick=()=>confirmation.close()
18
+ confirmation.addEventListener('cancel',e=>{e.stopPropagation()})
19
+ confirmation.addEventListener('keydown',e=>{if(e.key==='Escape')e.stopPropagation()})
12
20
  panel.append(dialog)
13
21
  const canvas=dialog.querySelector('canvas'),zoom=dialog.querySelector('input[type=range]'),status=dialog.querySelector('[role=status]')
14
22
  let bitmap,original,role,crop={zoom:1,x:.5,y:.5},drag,busy=false,disposed=false,generation=0,previousFocus,revision,hasCustom=false,ready=false
15
23
  const clean=()=>{bitmap?.close();bitmap=null;original=null;drag=null}
16
- const close=()=>{generation++;dialog.close?.();clean();previousFocus?.focus?.({preventScroll:true})}
24
+ const close=()=>{generation++;confirmation.close?.();dialog.close?.();clean();previousFocus?.focus?.({preventScroll:true})}
17
25
  const draw=()=>{
18
26
  zoom.value=String(crop.zoom)
19
27
  if(!bitmap){canvas.getContext('2d').clearRect(0,0,320,320);return}
@@ -103,8 +111,13 @@ export function mountAvatarCrop({panel,window,load,onSave}){
103
111
  catch{status.textContent='图片无法读取,请选择不超过 5 MB、2500 万像素的图片'}
104
112
  finally{busy=false;controls()}
105
113
  })
106
- dialog.querySelector('[data-crop-reset]').addEventListener('click',async()=>{
107
- if(busy||!hasCustom||!window.confirm('恢复默认头像将移除当前图片及裁剪设置,是否继续?'))return
114
+ dialog.querySelector('[data-crop-reset]').addEventListener('click',()=>{
115
+ if(busy||!hasCustom)return
116
+ confirmation.showModal();confirmation.querySelector('[data-keep]').focus()
117
+ })
118
+ confirmation.querySelector('[data-confirm]').addEventListener('click',async()=>{
119
+ if(busy||!hasCustom)return
120
+ confirmation.close()
108
121
  busy=true;controls()
109
122
  try{await onSave(role,null,undefined,revision);if(!disposed)close()}
110
123
  catch(error){status.textContent=error.message}
@@ -76,7 +76,8 @@ export function mountBackgroundRegionEditor({section,window,store}) {
76
76
  event.preventDefault();const bounds=stage.getBoundingClientRect();if(!bounds.width||!bounds.height)return
77
77
  const x=clamp((event.clientX-bounds.left)/bounds.width,0,1),y=clamp((event.clientY-bounds.top)/bounds.height,0,1)
78
78
  drag={x,y,bounds,initial:{...draft},corner:event.target.dataset.prtsRegionHandle,id:event.pointerId}
79
- stage.style.cursor=window.getComputedStyle(event.target).cursor
79
+ const cursorStyle=window.getComputedStyle(event.target)
80
+ stage.style.cursor=cursorStyle.getPropertyValue('--prts-source-cursor').trim() || cursorStyle.cursor
80
81
  stage.setPointerCapture?.(event.pointerId);draw()
81
82
  }
82
83
  function adjust(initial,dx,dy,corner){
@@ -146,7 +146,7 @@ export function createConversationControlAdapter({ document, window }) {
146
146
  }
147
147
  }
148
148
 
149
- // One shared frame follows body targets, including transitions between neighboring controls.
149
+ // One shared frame for card transitions and nearby compact controls.
150
150
  function mountBodyTargetFrame(document, window, operation) {
151
151
  // Nonvisual hosts can still mount the theme without an animation clock.
152
152
  if (!window?.requestAnimationFrame || !window?.cancelAnimationFrame) return () => {}
@@ -157,9 +157,46 @@ function mountBodyTargetFrame(document, window, operation) {
157
157
  const corners=Array.from({length:4},()=>frame.appendChild(document.createElement('i')))
158
158
  document.body.append(frame)
159
159
  if(frame.showPopover)frame.setAttribute('popover','manual')
160
+ let ghost=null, ghostCorners=[], ghostAnimations=[]
161
+ const clearGhost=()=>{
162
+ for(const animation of ghostAnimations)animation.cancel()
163
+ if(ghost)ghost.style.opacity='0'
164
+ }
165
+ const retireGhost=()=>{
166
+ // Allocate one reusable layer and five animations per adapter lifetime.
167
+ // Repeated handoffs must not churn body portals or their observers.
168
+ if(!ghost){
169
+ ghost=frame.cloneNode(true)
170
+ ghost.removeAttribute('data-prts-body-target-frame')
171
+ ghost.setAttribute('data-prts-body-target-ghost','')
172
+ ghost.removeAttribute('data-pressed')
173
+ ghost.style.opacity='0'
174
+ ghostCorners=[...ghost.children]
175
+ document.body.append(ghost)
176
+ ghostAnimations=[...ghostCorners,ghost].map(node=>{
177
+ const animation=node.animate([],{duration:80,easing:'ease-out',fill:'forwards'})
178
+ animation.cancel()
179
+ return animation
180
+ })
181
+ ghostAnimations[4].onfinish=()=>{
182
+ if(ghostAnimations[4]?.playState==='finished')clearGhost()
183
+ }
184
+ }
185
+ ghost.style.color=frame.style.color
186
+ ghost.style.setProperty('--prts-arm-scale',frame.style.getPropertyValue('--prts-arm-scale'))
187
+ if(ghost.showPopover && !ghost.matches(':popover-open'))ghost.showPopover()
188
+ for(const [index,corner] of corners.entries()){
189
+ const from=corner.style.transform
190
+ const dx=index%2?-4:4,dy=index<2?4:-4
191
+ ghostAnimations[index].effect.setKeyframes([{transform:from},{transform:from+' translate('+dx+'px,'+dy+'px)'}])
192
+ }
193
+ ghostAnimations[4].effect.setKeyframes([{opacity:frame.style.opacity},{opacity:0}])
194
+ for(const animation of ghostAnimations){animation.currentTime=0;animation.play()}
195
+ }
160
196
  let layer=null, imageRetiring=false
161
197
  const hideFrame=()=>{
162
198
  if(frame.hidePopover && frame.matches(':popover-open'))frame.hidePopover()
199
+ if(ghost?.hidePopover && ghost.matches(':popover-open'))ghost.hidePopover()
163
200
  }
164
201
  const showFrame=()=>{
165
202
  if(!frame.showPopover)return
@@ -171,6 +208,8 @@ function mountBodyTargetFrame(document, window, operation) {
171
208
  let target=null, hovered=null, focused=null, raf=0, pressed=false, releasePending=false, disposed=false
172
209
  let pointer={x:0,y:0}, state=null, settled=false, tween=null, bounds=null, boundsDirty=false, hitDirty=false, exitGoal=null, motionDuration=240, targetArm=7, retiringBounds=null, targetRetires=false
173
210
  let moveRaf=0, pendingMove=null
211
+ let spring=false, springTime=null, springStart=null, velocity={}
212
+ let departed=null
174
213
  const resizeObserver=window.ResizeObserver?new window.ResizeObserver(entries=>{
175
214
  if(!target)return
176
215
  const entry=entries.find(item=>item.target===target),size=entry?.borderBoxSize?.[0]
@@ -182,16 +221,26 @@ function mountBodyTargetFrame(document, window, operation) {
182
221
  const reduced=window.matchMedia?.('(prefers-reduced-motion: reduce)')
183
222
  const fine=window.matchMedia?.('(hover:hover) and (pointer:fine)')
184
223
  let activeModal=null
224
+ const cardSelector='[data-presented-file],[data-changed-files],[data-tool="schedule_create"]'
185
225
  const find=node=>{
186
226
  if(activeModal && !activeModal.contains(node))return null
187
227
  if (!node?.closest || node.closest('[data-prts-workspace-actions],[data-prts-session-actions]')) return null
188
- const schedule=node.closest('[data-tool="schedule_create"]')
189
- const scheduleOpen=schedule && [...schedule.children].find(child=>child.tagName==='BUTTON' && hasClassSuffix(child,'_cardOpen') && !child.matches(':disabled,[aria-disabled="true"]'))
228
+ const container=node.closest(cardSelector)
190
229
  const button=node.closest('button')
191
- // The transparent hit layer targets the card; nested actions keep their own frame.
192
- const card=node.closest('[data-presented-file],[data-changed-files]') || (scheduleOpen && (!button || button===scheduleOpen)?schedule:null)
230
+ let cardTarget=null
231
+ if(container){
232
+ const action=node.closest('button,a[href],[role="button"],[role="link"],summary,input:not([type="hidden"]),select,textarea')
233
+ const nested=action && action!==container && container.contains(action)
234
+ // Disabled controls must not fall back to their enclosing card.
235
+ if(node.closest(':disabled,[aria-disabled="true"],[inert]'))return null
236
+ const hitLayer=nested && action.tagName==='BUTTON' && (
237
+ container.matches('[data-presented-file]') && hasClassSuffix(action,'_cardPreview') ||
238
+ action.parentElement===container && container.matches('[data-tool="schedule_create"]') && hasClassSuffix(action,'_cardOpen'))
239
+ const openable=!container.matches('[data-tool="schedule_create"]') || [...container.children].some(child=>child.tagName==='BUTTON' && hasClassSuffix(child,'_cardOpen') && !child.matches(':disabled,[aria-disabled="true"]'))
240
+ cardTarget=nested && !hitLayer ? action : openable ? container : null
241
+ }
193
242
  const pendingAction=operation.contains(button) && isPendingInteractionAction(button) ? button : null
194
- const candidate=card || pendingAction || node.closest(selector)
243
+ const candidate=cardTarget || pendingAction || node.closest(selector)
195
244
  return candidate && !candidate.matches(':disabled,[aria-disabled="true"]') && !candidate.closest('[inert]') ? candidate : null
196
245
  }
197
246
  const measure=(box)=>{bounds=box || target.getBoundingClientRect();targetArm=7+Math.max(0,Math.min(1,(Math.min(bounds.width,bounds.height)-36)/36))*5;boundsDirty=false}
@@ -200,20 +249,55 @@ function mountBodyTargetFrame(document, window, operation) {
200
249
  const next=(hovered?.isConnected?hovered:null) || (focused?.isConnected?focused:null)
201
250
  if(next!==target){
202
251
  const nextBounds=next?.getBoundingClientRect()
252
+ const now=window.performance.now()
253
+ const previous=target || (departed && now-departed.time<=100?departed.node:null)
254
+ const previousBounds=target?bounds:departed?.bounds
255
+ const card=previous?.closest(cardSelector)
256
+ const sameCard=Boolean(target && card && card===next?.closest(cardSelector))
257
+ const compact=(node,box)=>node?.matches('button,a[href],[role="button"],[role="link"]')
258
+ && box?.width>0 && box.width<=160 && box.height>0 && box.height<=64
259
+ const nearby=Boolean(next && previous?.isConnected && !card && !next.closest(cardSelector)
260
+ && compact(previous,previousBounds) && compact(next,nextBounds)
261
+ && previous.closest('dialog,[role="dialog"]')===next.closest('dialog,[role="dialog"]'))
262
+ const eligible=Boolean(next && previous?.isConnected && (sameCard || nearby)
263
+ && state?.alpha>0 && !reduced?.matches)
264
+ // Maximum corner travel catches wide-card -> edge-button transitions,
265
+ // even when the two centers happen to be close.
266
+ const travel=eligible?Math.max(
267
+ Math.hypot(state.x-(nextBounds.left-5),state.y-(nextBounds.top-5)),
268
+ Math.hypot(state.x+state.w-(nextBounds.right+5),state.y-(nextBounds.top-5)),
269
+ Math.hypot(state.x-(nextBounds.left-5),state.y+state.h-(nextBounds.bottom+5)),
270
+ Math.hypot(state.x+state.w-(nextBounds.right+5),state.y+state.h-(nextBounds.bottom+5))):0
271
+ const scale=eligible?Math.max(state.w/(nextBounds.width+10),(nextBounds.width+10)/state.w,state.h/(nextBounds.height+10),(nextBounds.height+10)/state.h):1
272
+ const morph=eligible && (sameCard || travel<=96 && scale<=2)
273
+ const handoff=morph && (travel>96 || scale>2)
274
+ if(!next && target)departed={node:target,bounds,time:now}
275
+ else if(next)departed=null
276
+ clearGhost()
277
+ if(handoff && frame.animate)retireGhost()
278
+ spring=morph && !handoff;springStart=null
279
+ if(!spring){springTime=null;velocity={}}
203
280
  const retire=!next && target && !target.isConnected && targetRetires && releasePending && bounds && !reduced?.matches
204
281
  imageRetiring=false
205
282
  retiringBounds=retire?{left:bounds.left,top:bounds.top,width:bounds.width,height:bounds.height}:null
206
283
  target?.removeAttribute('data-prts-corner-target')
207
284
  if(retire){hovered=focused=target=null;boundsDirty=false;resizeObserver?.disconnect();wake();return}
208
- pressed=false;releasePending=false;settled=false;tween=null;exitGoal=null;motionDuration=240;target=next;boundsDirty=true
285
+ pressed=false;releasePending=false;settled=false;tween=null;exitGoal=null;motionDuration=morph?120:240;target=next;boundsDirty=true
209
286
  resizeObserver?.disconnect()
210
287
  if(target)resizeObserver?.observe(target)
211
288
  if(target){
212
289
  targetRetires=hasClassSuffix(target,'_toBottom') || isPendingInteractionAction(target)
213
290
  target.setAttribute('data-prts-corner-target','');showFrame()
214
291
  measure(nextBounds);const box=bounds
215
- state={x:(hovered?pointer.x:box.left+box.width/2)-10,y:(hovered?pointer.y:box.top+box.height/2)-10,w:20,h:20,alpha:0,arm:7}
216
- frame.style.opacity='0'
292
+ // Preserve the last painted geometry when interrupted: no restart from
293
+ // the pointer, opacity flash, queued animations, or extra layout reads.
294
+ if(handoff){
295
+ state={x:box.left-12,y:box.top-12,w:box.width+24,h:box.height+24,alpha:0,arm:targetArm}
296
+ frame.style.opacity='0'
297
+ }else if(!morph){
298
+ state={x:(hovered?pointer.x:box.left+box.width/2)-10,y:(hovered?pointer.y:box.top+box.height/2)-10,w:20,h:20,alpha:0,arm:7}
299
+ frame.style.opacity='0'
300
+ }
217
301
  frame.style.color=target.getAttribute('data-prts-conversation-control')==='danger'?window.getComputedStyle(target).getPropertyValue('--prts-red'):'var(--prts-corner-color, var(--prts-yellow))'
218
302
  }
219
303
  }
@@ -239,15 +323,40 @@ function mountBodyTargetFrame(document, window, operation) {
239
323
  const edgeRight=pixel(goal.x+goal.w),edgeBottom=pixel(goal.y+goal.h)
240
324
  goal.x=pixel(goal.x);goal.y=pixel(goal.y);goal.w=edgeRight-goal.x;goal.h=edgeBottom-goal.y
241
325
  const keys=['x','y','w','h','alpha','arm']
242
- if(!tween || keys.some(key=>tween.to[key]!==goal[key])){
243
- const remaining=tween && tween.pressed===pressed?Math.max(0,tween.start+tween.duration-time):motionDuration
244
- tween={from:{...state},to:{...goal},start:time,duration:remaining,pressed}
326
+ let moving=false
327
+ if(spring && target && !pressed && !reduced?.matches){
328
+ // Exact critically damped solution: stable at variable frame rates, with
329
+ // velocity retained across target changes. No physics substep loop.
330
+ const dt=springTime===null?0:Math.max(0,(time-springTime)/1000)
331
+ springTime=time;springStart ??= time
332
+ const omega=48, decay=Math.exp(-omega*dt)
333
+ for(const key of keys){
334
+ const offset=state[key]-goal[key],speed=velocity[key] || 0
335
+ const coefficient=speed+omega*offset
336
+ state[key]=goal[key]+(offset+coefficient*dt)*decay
337
+ velocity[key]=(speed-omega*coefficient*dt)*decay
338
+ const tolerance=key==='alpha'?.002:.08
339
+ if(Math.abs(state[key]-goal[key])>tolerance || Math.abs(velocity[key])>tolerance*48)moving=true
340
+ }
341
+ // Stop together, with a bounded tail even after a busy main-thread frame.
342
+ if(!moving || time-springStart>=400){
343
+ Object.assign(state,goal);velocity={};spring=false;springTime=null;moving=false
344
+ }
345
+ }else{
346
+ spring=false;springTime=null;springStart=null
347
+ if(!tween || keys.some(key=>tween.to[key]!==goal[key])){
348
+ const remaining=tween && tween.pressed===pressed?Math.max(0,tween.start+tween.duration-time):motionDuration
349
+ tween={from:{...state},to:{...goal},start:time,duration:remaining,pressed}
350
+ }
351
+ // Keep instantaneous velocity available if a card morph interrupts entry.
352
+ const progress=reduced?.matches || settled || !tween.duration || (!state.alpha && !goal.alpha)?1:Math.min(1,(time-tween.start)/tween.duration)
353
+ const ease=1-(1-progress)**3
354
+ for(const key of keys){
355
+ state[key]=tween.from[key]+(goal[key]-tween.from[key])*ease
356
+ velocity[key]=progress<1?(goal[key]-tween.from[key])*3*(1-progress)**2*1000/tween.duration:0
357
+ }
358
+ moving=progress<1
245
359
  }
246
- // All channels share one endpoint; no separate thresholds or late position snap.
247
- const progress=reduced?.matches || settled || !tween.duration || (!state.alpha && !goal.alpha)?1:Math.min(1,(time-tween.start)/tween.duration)
248
- const ease=1-(1-progress)**3
249
- for(const key of keys)state[key]=tween.from[key]+(goal[key]-tween.from[key])*ease
250
- const moving=progress<1
251
360
  const x=state.x,y=state.y
252
361
  const right=state.x+state.w-7,bottom=state.y+state.h-7
253
362
  const positions=[[x,y],[right,y],[x,bottom],[right,bottom]]
@@ -289,7 +398,7 @@ function mountBodyTargetFrame(document, window, operation) {
289
398
  const focus=event=>{focused=event.target.matches?.(':focus-visible')?find(event.target):null;choose()}
290
399
  const blur=()=>{focused=null;choose()}
291
400
  const down=event=>{
292
- flushMove()
401
+ flushMove();clearGhost()
293
402
  pointer={x:event.clientX,y:event.clientY}
294
403
  if(event.pointerType==='touch')hovered=find(event.target)
295
404
  choose()
@@ -303,10 +412,11 @@ function mountBodyTargetFrame(document, window, operation) {
303
412
  }
304
413
  const up=event=>{release();if(event.pointerType==='touch')hovered=null;choose()}
305
414
  const reset=()=>{
306
- clearMove()
415
+ clearGhost();clearMove()
307
416
  target?.removeAttribute('data-prts-corner-target');hideFrame()
308
417
  hovered=focused=target=null;state=tween=bounds=exitGoal=retiringBounds=null
309
418
  imageRetiring=false
419
+ spring=false;springTime=springStart=null;velocity={};departed=null
310
420
  pressed=false;releasePending=false;settled=true;boundsDirty=hitDirty=false
311
421
  resizeObserver?.disconnect();window.cancelAnimationFrame(raf);raf=0
312
422
  frame.style.opacity='0';frame.removeAttribute('data-pressed')
@@ -346,11 +456,12 @@ function mountBodyTargetFrame(document, window, operation) {
346
456
  ]
347
457
  for(const [node,event,handler] of listeners)node.addEventListener(event,handler)
348
458
  return ()=>{
349
- clearMove()
459
+ clearGhost();clearMove()
350
460
  modalObserver?.disconnect();document.removeEventListener('toggle',modalToggle,true)
351
461
  disposed=true;target?.removeAttribute('data-prts-corner-target');hideFrame();window.cancelAnimationFrame(raf);resizeObserver?.disconnect()
352
462
  document.removeEventListener('scroll',scroll,true);window.removeEventListener('resize',reset)
353
463
  for(const [node,event,handler] of listeners)node.removeEventListener(event,handler)
464
+ ghost?.remove();ghost=null;ghostAnimations=[];ghostCorners=[]
354
465
  frame.remove()
355
466
  }
356
467
  }
@@ -2,12 +2,15 @@
2
2
  export function createCursorAdapter({ document, window, assets }) {
3
3
  const root = document?.documentElement
4
4
  let listening = false
5
+ let animate = true
5
6
  let pointerId
6
7
  let timer
8
+ let compact = false, pressedAt = 0
7
9
  let handPhase
8
10
  let gestureAssets = assets
9
11
  let textTarget
10
12
  let moveFrame, pendingMove
13
+ let textRange
11
14
  let lastPoint
12
15
  const heroTextSelector = '[data-phase="hero"] [class$="_titleGroup"]'
13
16
  const contentSelector = '[data-markdown], [data-slot*="markdown" i], [class*="markdown" i], :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"], [data-submission-echo], [data-pending-steering]) [class$="_bubble"]'
@@ -23,14 +26,19 @@ export function createCursorAdapter({ document, window, assets }) {
23
26
  textTarget = nextTarget
24
27
  textTarget?.setAttribute('data-prts-text-hit', '')
25
28
  }
26
- function findTextHit(event) {
29
+ function prepareTextHit(target, semanticOnly = true) {
30
+ const content = target?.closest?.(`${contentSelector}, ${heroTextSelector}`)
31
+ if (!content || (!content.matches(heroTextSelector) && !content.closest('[data-chat-flow-kind], [data-message-role], [data-submission-echo], [data-pending-steering]')) || target.closest(controlSelector)) return {target}
32
+ const style = window.getComputedStyle(target)
33
+ if (style.userSelect === 'none' || (!semanticOnly && !style.cursor.includes('data:image/png'))) return {target}
34
+ return {target,content}
35
+ }
36
+ function findTextHit(event, semanticOnly = false, prepared) {
27
37
  if (event.pointerType !== 'mouse') return
28
38
  lastPoint = { clientX: event.clientX, clientY: event.clientY, pointerType: 'mouse' }
29
39
  const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement
30
- const content = target?.closest?.(`${contentSelector}, ${heroTextSelector}`)
31
- if (!content || (!content.matches(heroTextSelector) && !content.closest('[data-chat-flow-kind], [data-message-role], [data-submission-echo], [data-pending-steering]')) || target.closest(controlSelector)) return
32
- const style = window.getComputedStyle(target)
33
- if (style.userSelect === 'none' || !style.cursor.includes('data:image/png')) return
40
+ const {content} = prepared?.target===target ? prepared : prepareTextHit(target,semanticOnly)
41
+ if (!content) return
34
42
  // Caret APIs return the nearest insertion point even over empty space.
35
43
  // Check its adjacent character rectangles before treating it as a text hit.
36
44
  const caret = document.caretPositionFromPoint?.(event.clientX, event.clientY)
@@ -38,8 +46,8 @@ export function createCursorAdapter({ document, window, assets }) {
38
46
  const node = caret?.offsetNode ?? fallback?.startContainer
39
47
  const offset = caret?.offset ?? fallback?.startOffset
40
48
  if (node?.nodeType !== 3 || !content.contains(node) || node.parentElement?.closest(controlSelector)) return
41
- if (window.getComputedStyle(node.parentElement).userSelect === 'none') return
42
- const range = document.createRange()
49
+ if (node.parentElement !== target && window.getComputedStyle(node.parentElement).userSelect === 'none') return
50
+ const range = textRange ||= document.createRange()
43
51
  for (const index of [offset - 1, offset]) {
44
52
  if (index < 0 || index >= node.length) continue
45
53
  range.setStart(node, index)
@@ -111,7 +119,7 @@ export function createCursorAdapter({ document, window, assets }) {
111
119
  lastHandFrame = undefined
112
120
  }
113
121
  // Cursor is inherited. Animating a large container still restyles its subtree.
114
- // Keep those background hits static, while controls retain native animation.
122
+ // Use one pressed pose for medium containers; keep larger backgrounds static.
115
123
  const maxAnimatedDescendants = 128
116
124
  function canAnimate(target) {
117
125
  return target && target.getElementsByTagName('*').length <= maxAnimatedDescendants
@@ -119,8 +127,9 @@ export function createCursorAdapter({ document, window, assets }) {
119
127
  function followHand(target) {
120
128
  if (stopIfHandChanged()) return
121
129
  if (target === handTarget && target !== textTarget) return
130
+ if(compact && handTarget && target!==handTarget){reset();return}
122
131
  restoreHandTarget()
123
- if (!canAnimate(target) || !handCursor || target === textTarget) return
132
+ if ((!compact && !canAnimate(target)) || !handCursor || target === textTarget) return
124
133
  const cursor = window.getComputedStyle(target).cursor
125
134
  if (!cursor.includes('data:image/png') || /(?:,\s*(?:text|grab|grabbing|not-allowed|no-drop)|resize)$/.test(cursor)) return
126
135
  handTarget = target
@@ -150,6 +159,7 @@ export function createCursorAdapter({ document, window, assets }) {
150
159
  handCursor = undefined
151
160
  root?.style.removeProperty('--prts-text-frame')
152
161
  handPhase = undefined
162
+ compact=false
153
163
  }
154
164
  function show(url) {
155
165
  const [x, y] = gestureAssets.hotspot
@@ -187,13 +197,19 @@ export function createCursorAdapter({ document, window, assets }) {
187
197
  updateTextHit(event)
188
198
  const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement
189
199
  reset()
200
+ if (!animate) return
190
201
  const cursor = target ? window.getComputedStyle(target).cursor : ''
191
202
  if (!cursor.includes('data:image/png')) return
192
- if (/(?:,\s*(?:text|grab|grabbing|not-allowed|no-drop)|resize)$/.test(cursor) || !canAnimate(target)) return
203
+ if (/(?:,\s*(?:text|grab|grabbing|not-allowed|no-drop)|resize)$/.test(cursor)) return
204
+ const descendants=target.getElementsByTagName('*').length
205
+ if(descendants>256)return
206
+ compact=descendants>maxAnimatedDescendants
207
+ pressedAt=window.performance.now()
193
208
  gestureAssets = /,\s*pointer$/.test(cursor) && assets.link ? assets.link : assets
194
209
  pointerId = event.pointerId
195
- handCursor = cursor
210
+ handCursor = compact ? `url("${gestureAssets.hold}") ${gestureAssets.hotspot[0]} ${gestureAssets.hotspot[1]}, pointer` : cursor
196
211
  followHand(target)
212
+ if(compact){handPhase='hold';return}
197
213
  if (reduced()) { clearTimer(); show(gestureAssets.hold); return }
198
214
  handPhase = 'press'
199
215
  play(gestureAssets.press, () => {
@@ -212,6 +228,12 @@ export function createCursorAdapter({ document, window, assets }) {
212
228
  if (pointerId === undefined || event.pointerId !== pointerId || event.button !== 0) return
213
229
  pointerId = undefined
214
230
  if (reduced()) { reset(); return }
231
+ if(compact){
232
+ const remaining=80-(window.performance.now()-pressedAt)
233
+ if(remaining>0)timer=window.setTimeout(reset,remaining)
234
+ else reset()
235
+ return
236
+ }
215
237
  if (handPhase === 'press') return
216
238
  releaseHand()
217
239
  }
@@ -252,16 +274,26 @@ export function createCursorAdapter({ document, window, assets }) {
252
274
  if (event.target === window) resetInteraction()
253
275
  }
254
276
  function visibility() { if (document.hidden) resetInteraction() }
255
- const events = [['pointerdown', down], ['pointerup', up], ['pointermove', move], ['pointercancel', resetInteraction], ['blur', blur], ['dragstart', resetInteraction], ['pointerout', leave], ['scroll', refreshTextHit], ['resize', refreshTextHit]]
277
+ function over(event) {
278
+ if (animate || event.pointerType !== 'mouse') return
279
+ clearPendingMove()
280
+ applyMove(event)
281
+ }
282
+ const events = [['pointerdown', down], ['pointerup', up], ['pointermove', move], ['pointerover', over], ['pointercancel', resetInteraction], ['blur', blur], ['dragstart', resetInteraction], ['pointerout', leave], ['scroll', refreshTextHit], ['resize', refreshTextHit]]
256
283
  function dispose() {
257
284
  resetInteraction()
285
+ textRange=undefined
258
286
  if (!listening) return
259
287
  for (const [name, handler] of events) window.removeEventListener(name, handler, true)
260
288
  document.removeEventListener('visibilitychange', visibility)
261
289
  listening = false
262
290
  }
263
291
  return {
292
+ prepareTextHit,
293
+ textHit: (event, prepared) => findTextHit(event, true, prepared),
264
294
  update(preferences) {
295
+ if (animate && preferences.cursorAnimation === false) reset()
296
+ animate = preferences.cursorAnimation !== false
265
297
  if (!root || !assets || !preferences.enabled || preferences.cursor !== 'prts') { dispose(); return }
266
298
  if (listening) return
267
299
  for (const [name, handler] of events) window.addEventListener(name, handler, true)