@ -9,6 +9,7 @@ import {
type StateProps
} from '$libs/reactive' ;
import type { OnChangeFn , SomaMouseEvent , SomaKeyboardEvent } from '../../types' ;
import type { TimerHandle } from '$timer' ;
import { KEYS } from '../../keyboard' ;
import { Soma } from '../../core/soma.svelte' ;
import { FLOAT_PANEL_LANGS } from './langs' ;
@ -318,7 +319,7 @@ export class FloatPanelProvider {
private preGrabPos : FloatPanelPosition | null = null ;
private preGrabSize : FloatPanelSize | null = null ;
private kbAxis : 'x' | 'y' = 'x' ;
private readoutTimer : number | null = null ;
private readoutTimer : TimerHandle | null = null ;
private readoutKind : 'position' | 'size' | null = null ;
private announce (
@ -337,22 +338,30 @@ export class FloatPanelProvider {
return { width : Math.round ( s . width ) , height : Math.round ( s . height ) } ;
}
private get readoutTimerKey() {
return ` soma:float-panel: ${ this . opts . id . current } :kb-readout ` ;
}
private scheduleReadout ( kind : 'position' | 'size' ) : void {
this . clearReadout ( ) ;
this . readoutKind = kind ;
const win = this . soma . dom . getWindow ( this . contentRef . current ? ? undefined ) ;
this . readoutTimer = win . setTimeout ( ( ) = > {
this . readoutTimer = null ;
if ( this . readoutKind === 'position' )
this . announce ( FLOAT_PANEL_LANGS . KB_POSITION , this . posParams ( this . currentPos ) ) ;
else if ( this . readoutKind === 'size' )
this . announce ( FLOAT_PANEL_LANGS . KB_SIZE , this . sizeParams ( this . currentSize ) ) ;
this . readoutKind = null ;
} , KB_ANNOUNCE_DEBOUNCE_MS ) as unknown as number ;
this . readoutTimer = this . soma . uix . timers . schedule (
this . readoutTimerKey ,
KB_ANNOUNCE_DEBOUNCE_MS ,
( ) = > {
this . readoutTimer = null ;
if ( this . readoutKind === 'position' )
this . announce ( FLOAT_PANEL_LANGS . KB_POSITION , this . posParams ( this . currentPos ) ) ;
else if ( this . readoutKind === 'size' )
this . announce ( FLOAT_PANEL_LANGS . KB_SIZE , this . sizeParams ( this . currentSize ) ) ;
this . readoutKind = null ;
} ,
{ replace : true , meta : { component : 'float-panel' , action : 'kb-readout' } }
) ;
}
private clearReadout ( ) : void {
if ( this . readoutTimer != null ) {
this . soma . dom . getWindow ( this . contentRef . current ? ? undefined ) . clearTimeout ( this . readoutTimer ) ;
if ( this . readoutTimer ) {
this . readoutTimer. cancel ( ) ;
this . readoutTimer = null ;
}
}
@ -370,11 +379,16 @@ export class FloatPanelProvider {
this . moveMode = false ;
this . preGrabPos = null ;
this . announce ( FLOAT_PANEL_LANGS . KB_POSITION , this . posParams ( this . currentPos ) ) ;
// Keyboard grab ends — same drop cue as the pointer gesture (the
// knob doctrine: keyboard paths sound through the SAME events).
void this . runtime . trigger ( 'drag-end' ) ;
return ;
}
this . preGrabPos = { . . . this . currentPos } ;
this . moveMode = true ;
this . announce ( FLOAT_PANEL_LANGS . KB_MOVE_ENTER ) ;
// Keyboard grab starts — the pointer gesture's pickup cue.
void this . runtime . trigger ( 'drag-start' ) ;
}
kbMove ( e : KeyboardEvent ) : void {
@ -411,6 +425,9 @@ export class FloatPanelProvider {
this . moveMode = false ;
this . preGrabPos = null ;
this . announce ( FLOAT_PANEL_LANGS . KB_CANCELLED ) ;
// The gesture ended (value reverted) — the drop cue still lands, like
// releasing a pointer drag after dragging back to the origin.
void this . runtime . trigger ( 'drag-end' ) ;
}
kbToggleResize ( ) : void {
@ -420,11 +437,15 @@ export class FloatPanelProvider {
this . resizeMode = false ;
this . preGrabSize = null ;
this . announce ( FLOAT_PANEL_LANGS . KB_SIZE , this . sizeParams ( this . currentSize ) ) ;
// Keyboard resize ends — same cue as the pointer gesture's release.
void this . runtime . trigger ( 'resize-end' ) ;
return ;
}
this . preGrabSize = { . . . this . currentSize } ;
this . resizeMode = true ;
this . announce ( FLOAT_PANEL_LANGS . KB_RESIZE_ENTER ) ;
// Keyboard resize starts — the pointer gesture's pickup cue.
void this . runtime . trigger ( 'resize-start' ) ;
}
kbResize ( e : KeyboardEvent ) : void {
@ -468,6 +489,8 @@ export class FloatPanelProvider {
this . resizeMode = false ;
this . preGrabSize = null ;
this . announce ( FLOAT_PANEL_LANGS . KB_CANCELLED ) ;
// The gesture ended (size reverted) — the release cue still lands.
void this . runtime . trigger ( 'resize-end' ) ;
}
readonly zIndex = $derived . by (
@ -663,7 +686,10 @@ export class FloatPanelProvider {
// keeps the (blurred) box-shadow baked in the layer instead of repainting
// it each frame. This also wins over the firma's `will-change` hint
// (events.css) that the drag-start signal would otherwise apply.
this . contentRef . current ? . style . setProperty ( 'will-change' , 'translate' ) ;
// (`writeProperty` = the ActiveDom per-element write vehicle — a SYNC
// setProperty delegate, so the same-task timing is preserved.)
if ( this . contentRef . current )
this . soma . dom . writeProperty ( this . contentRef . current , 'will-change' , 'translate' ) ;
const doc = this . soma . dom . getDocument ( this . dragEl ) ;
const move = this . soma . dom . listen ( doc , 'pointermove' , ( ev ) = > this . onDragMove ( ev as PointerEvent ) ) ;
const up = this . soma . dom . listen ( doc , 'pointerup' , ( ev ) = > this . endDrag ( ev as PointerEvent ) ) ;
@ -711,7 +737,12 @@ export class FloatPanelProvider {
// blurs the panel's text/borders. Snapping to the pixel grid keeps it crisp
// (ark-ui stays sharp because it positions with integer left/top + no layer).
const el = this . contentRef . current ;
if ( el ) el . style . translate = ` ${ this . snapPx ( clamped . x ) } px ${ this . snapPx ( clamped . y ) } px ` ;
if ( el )
this . soma . dom . writeProperty (
el ,
'translate' ,
` ${ this . snapPx ( clamped . x ) } px ${ this . snapPx ( clamped . y ) } px `
) ;
}
private endDrag ( e : PointerEvent ) : void {
@ -773,7 +804,8 @@ export class FloatPanelProvider {
// Lift + promote on pointerdown (see startDrag). `resizeMoved` still gates
// the actual size update + resize-start signal on real movement.
this . resizing = true ;
this . contentRef . current ? . style . setProperty ( 'will-change' , 'translate' ) ;
if ( this . contentRef . current )
this . soma . dom . writeProperty ( this . contentRef . current , 'will-change' , 'translate' ) ;
const doc = this . soma . dom . getDocument ( this . resizeEl ) ;
const move = this . soma . dom . listen ( doc , 'pointermove' , ( ev ) = >
this . onResizeMove ( ev as PointerEvent )
@ -826,13 +858,14 @@ export class FloatPanelProvider {
this . resizeLiveSize = clamped ;
this . resizeLivePos = { x , y } ;
// Direct DOM write (synchronous, no re-render); state committed on release.
// Round to whole pixels — crisp on the GPU layer (see onDragMove).
// Direct per-element write via ActiveDom (synchronous, no re-render);
// state committed on release. Round to whole pixels — crisp on the GPU
// layer (see onDragMove).
const el = this . contentRef . current ;
if ( el ) {
el . style . width = ` ${ this . snapPx ( clamped . width ) } px ` ;
el . style . height = ` ${ this . snapPx ( clamped . height ) } px ` ;
el . style . translate = ` ${ this . snapPx ( x ) } px ${ this . snapPx ( y ) } px ` ;
this . soma . dom . writeProperty ( el , 'width' , ` ${ this . snapPx ( clamped . width ) } px ` ) ;
this . soma . dom . writeProperty ( el , 'height' , ` ${ this . snapPx ( clamped . height ) } px ` ) ;
this . soma . dom . writeProperty ( el , 'translate' , ` ${ this . snapPx ( x ) } px ${ this . snapPx ( y ) } px ` ) ;
}
}
@ -999,28 +1032,37 @@ export class FloatPanelContentProvider {
// Imperative inline style — this effect (plus the drag/resize handlers in
// the parent provider) is the SOLE owner of the content element's geometry
// and stacking. Each property is set individually (never via cssText), so
// writes compose instead of clobbering, and the drag handler's direct
// `el.style.translate` survives the will-change / z-index re-applies that
// happen at gesture start. At rest this applies seeded / programmatic /
// post-gesture geometry; DURING a gesture it cedes translate/size to the
// handlers (guarded on `active`) so the hot pointer path owns the DOM alone.
// and stacking, all through ActiveDom's per-element `writeProperty` (sync).
// Each property is set individually (never via cssText), so writes compose
// instead of clobbering, and the drag handler's direct `translate` write
// survives the will-change / z-index re-applies that happen at gesture
// start. At rest this applies seeded / programmatic / post-gesture
// geometry; DURING a gesture it cedes translate/size to the handlers
// (guarded on `active`) so the hot pointer path owns the DOM alone.
$effect ( ( ) = > {
const el = this . opts . ref . current ;
if ( ! el ) return ;
const p = this . provider ;
const dom = p . soma . dom ;
const active = p . dragging || p . resizing ;
const stage = p . stage ;
const pos = p . currentPos ;
const size = p . currentSize ;
// Chrome: cheap, changes only on strategy / bringToFront / gesture toggle.
el . style . position = p . opts . strategy . current ;
el . style . left = '0px' ;
el . style . top = '0px' ;
el . style . pointerEvents = 'auto' ;
el . style . zIndex = String ( p . zIndex ) ;
el . style . willChange = active ? 'translate, scale' : '' ;
el . style . userSelect = active ? 'none' : '' ;
// (Per-element writes via ActiveDom's `writeProperty` — a SYNC
// setProperty delegate, so ordering vs the gesture handlers holds.)
dom . writeProperty ( el , 'position' , p . opts . strategy . current ) ;
dom . writeProperty ( el , 'left' , '0px' ) ;
dom . writeProperty ( el , 'top' , '0px' ) ;
dom . writeProperty ( el , 'pointer-events' , 'auto' ) ;
dom . writeProperty ( el , 'z-index' , String ( p . zIndex ) ) ;
if ( active ) {
dom . writeProperty ( el , 'will-change' , 'translate, scale' ) ;
dom . writeProperty ( el , 'user-select' , 'none' ) ;
} else {
dom . removeProperty ( el , 'will-change' ) ;
dom . removeProperty ( el , 'user-select' ) ;
}
// Geometry: only when no gesture is driving the element directly.
// Round to whole pixels — `translate` is a transform and doesn't snap to
// the pixel grid, so a fractional value blurs the panel's text even at
@ -1030,19 +1072,19 @@ export class FloatPanelContentProvider {
// Fill the bounds. The stored pos/size are left untouched so a
// restore to 'default' snaps straight back — no rect bookkeeping.
const m = p . maximizedRect ( ) ;
el. style . translate = ` ${ p . snapPx ( m . x ) } px ${ p . snapPx ( m . y ) } px ` ;
el. style . width = ` ${ p . snapPx ( m . width ) } px ` ;
el. style . height = ` ${ p . snapPx ( m . height ) } px ` ;
dom. writeProperty ( el , 'translate' , ` ${ p . snapPx ( m . x ) } px ${ p . snapPx ( m . y ) } px ` ) ;
dom. writeProperty ( el , 'width' , ` ${ p . snapPx ( m . width ) } px ` ) ;
dom. writeProperty ( el , 'height' , ` ${ p . snapPx ( m . height ) } px ` ) ;
} else if ( stage === 'minimized' ) {
// Collapse to the header: keep position + width, let height auto-fit
// (the recipe hides the body under `[data-stage='minimized']`).
el. style . translate = ` ${ p . snapPx ( pos . x ) } px ${ p . snapPx ( pos . y ) } px ` ;
el. style . width = ` ${ p . snapPx ( size . width ) } px ` ;
el. style . height = '' ;
dom. writeProperty ( el , 'translate' , ` ${ p . snapPx ( pos . x ) } px ${ p . snapPx ( pos . y ) } px ` ) ;
dom. writeProperty ( el , 'width' , ` ${ p . snapPx ( size . width ) } px ` ) ;
dom. removeProperty ( el , 'height' ) ;
} else {
el. style . translate = ` ${ p . snapPx ( pos . x ) } px ${ p . snapPx ( pos . y ) } px ` ;
el. style . width = ` ${ p . snapPx ( size . width ) } px ` ;
el. style . height = ` ${ p . snapPx ( size . height ) } px ` ;
dom. writeProperty ( el , 'translate' , ` ${ p . snapPx ( pos . x ) } px ${ p . snapPx ( pos . y ) } px ` ) ;
dom. writeProperty ( el , 'width' , ` ${ p . snapPx ( size . width ) } px ` ) ;
dom. writeProperty ( el , 'height' , ` ${ p . snapPx ( size . height ) } px ` ) ;
}
} ) ;
}