@ -172,6 +172,15 @@ export class WordsProvider {
slashMenuMounts = $state ( 0 ) ;
slashMenuMounts = $state ( 0 ) ;
slashMenuActiveIndex = $state ( 0 ) ;
slashMenuActiveIndex = $state ( 0 ) ;
slashDismissedAnchorKey = $state < string | null > ( null ) ;
slashDismissedAnchorKey = $state < string | null > ( null ) ;
/ * *
* Atomic block selection — block index pointing to an image ( or
* any other contenteditable = false block we add later ) . Distinct
* from the text selection because the caret can never live inside
* an atomic block . Set by the eidos click handler when the user
* clicks an image ; cleared by any selectionchange / focus loss /
* text - editing event .
* /
selectedBlockIndex = $state < number | undefined > ( undefined ) ;
private pendingCommitReason : WordsCommitReason = 'programmatic' ;
private pendingCommitReason : WordsCommitReason = 'programmatic' ;
private typingBatchOpen = false ;
private typingBatchOpen = false ;
@ -279,7 +288,8 @@ export class WordsProvider {
readonly html = $derived . by ( ( ) = >
readonly html = $derived . by ( ( ) = >
renderWordsDomHtml ( this . document , {
renderWordsDomHtml ( this . document , {
matches : this.findMatches ,
matches : this.findMatches ,
activeMatchIndex : this.currentMatchIndex
activeMatchIndex : this.currentMatchIndex ,
selectedBlockIndex : this.selectedBlockIndex
} )
} )
) ;
) ;
@ -308,10 +318,41 @@ export class WordsProvider {
} ) ;
} ) ;
readonly currentBlock = $derived . by ( ( ) = > {
readonly currentBlock = $derived . by ( ( ) = > {
// Atomic block selection (image / etc) takes precedence over
// text-selection-derived currentBlock — the caret can't live
// inside an atomic block, so without this short-circuit the
// drawer would never enter the atomic block's mode.
if ( this . selectedBlockIndex !== undefined ) {
const atomic = this . document . children [ this . selectedBlockIndex ] ;
if ( atomic ) return atomic . type ;
}
const index = this . selection ? . anchor . path [ 0 ] ? ? 0 ;
const index = this . selection ? . anchor . path [ 0 ] ? ? 0 ;
return this . document . children [ index ] ? . type ? ? 'paragraph' ;
return this . document . children [ index ] ? . type ? ? 'paragraph' ;
} ) ;
} ) ;
/ * *
* Mark an atomic block ( image / etc ) as visually selected . The
* eidos render reads this via ` selectedBlockIndex ` to paint the
* accent outline ; the drawer picks it up via ` currentBlock ` to
* enter the block ' s mode . Distinct from ` selectBlock() ` ( which
* extends the text selection to cover the current block ' s
* contents ) .
* /
selectAtomicBlock ( blockIndex : number ) : void {
if ( blockIndex < 0 || blockIndex >= this . document . children . length ) return ;
this . selectedBlockIndex = blockIndex ;
// Focus the editor root so keyboard handlers (esc, delete) work
// on the atomic block.
const root = this . opts . ref . current ;
if ( root && typeof ( root as HTMLElement ) . focus === 'function' ) {
( root as HTMLElement ) . focus ( { preventScroll : true } as FocusOptions ) ;
}
}
clearSelectedBlock ( ) : void {
if ( this . selectedBlockIndex !== undefined ) this . selectedBlockIndex = undefined ;
}
readonly currentHeadingLevel = $derived . by ( ( ) = > {
readonly currentHeadingLevel = $derived . by ( ( ) = > {
const index = this . selection ? . anchor . path [ 0 ] ? ? 0 ;
const index = this . selection ? . anchor . path [ 0 ] ? ? 0 ;
const block = this . document . children [ index ] ;
const block = this . document . children [ index ] ;
@ -474,6 +515,15 @@ export class WordsProvider {
if ( this . shouldIgnoreTransientSelectionCollapse ( next , root , options . source ? ? 'direct' ) ) {
if ( this . shouldIgnoreTransientSelectionCollapse ( next , root , options . source ? ? 'direct' ) ) {
return false ;
return false ;
}
}
// Clear the atomic-block highlight only when the text selection
// genuinely moved (the user clicked somewhere else / pressed an
// arrow / typed). Synthetic selectionchange echoes that arrive
// right after `selectAtomicBlock` (which doesn't actually move
// the DOM caret) would otherwise wipe the highlight a tick
// later, defeating the click.
if ( ! sameWordsSelection ( this . selection , next ) ) {
this . clearSelectedBlock ( ) ;
}
this . updateSelection ( next ) ;
this . updateSelection ( next ) ;
return true ;
return true ;
}
}
@ -864,11 +914,28 @@ export class WordsProvider {
this . selectBlock ( ) ;
this . selectBlock ( ) ;
return ;
return ;
}
}
if ( ! this . isEditable ) return ;
const target = e . target ;
const target = e . target ;
const targetElement =
const targetElement =
target instanceof Element ? target : target instanceof Node ? target.parentElement : null ;
target instanceof Element ? target : target instanceof Node ? target.parentElement : null ;
if ( ! targetElement ) return ;
if ( ! targetElement ) return ;
// Atomic-block click — figure[data-words-block='image'] (and any
// future atomic block types). Don't preventDefault: the browser
// will still resolve a caret-friendly text position; we just
// flag the block as the visually-selected atomic so the drawer
// can enter image mode.
const atomicFigure = targetElement . closest (
'figure[data-words-block="image"][data-words-path]'
) ;
if ( atomicFigure && e . currentTarget . contains ( atomicFigure ) ) {
const path = decodeWordsPath ( atomicFigure . getAttribute ( 'data-words-path' ) ) ;
const blockIndex = path ? . [ 0 ] ;
if ( typeof blockIndex === 'number' ) {
e . preventDefault ( ) ;
this . selectAtomicBlock ( blockIndex ) ;
return ;
}
}
if ( ! this . isEditable ) return ;
const checkToggle = targetElement . closest ( '[data-words-check-toggle][data-words-path]' ) ;
const checkToggle = targetElement . closest ( '[data-words-check-toggle][data-words-path]' ) ;
if ( checkToggle && e . currentTarget . contains ( checkToggle ) ) {
if ( checkToggle && e . currentTarget . contains ( checkToggle ) ) {
e . preventDefault ( ) ;
e . preventDefault ( ) ;
@ -1083,6 +1150,7 @@ export class WordsProvider {
readonly snippetProps = $derived . by ( ( ) = > ( {
readonly snippetProps = $derived . by ( ( ) = > ( {
document : this . document ,
document : this . document ,
selection : this.selection ,
selection : this.selection ,
selectedBlockIndex : this.selectedBlockIndex ,
plainText : this.plainText ,
plainText : this.plainText ,
isEmpty : this.isEmpty ,
isEmpty : this.isEmpty ,
isFocused : this.focused ,
isFocused : this.focused ,
@ -1122,6 +1190,8 @@ export class WordsProvider {
focus : ( ) = > this . focus ( ) ,
focus : ( ) = > this . focus ( ) ,
selectAll : ( ) = > this . selectAll ( ) ,
selectAll : ( ) = > this . selectAll ( ) ,
selectBlock : ( ) = > this . selectBlock ( ) ,
selectBlock : ( ) = > this . selectBlock ( ) ,
selectAtomicBlock : ( blockIndex : number ) = > this . selectAtomicBlock ( blockIndex ) ,
clearSelectedBlock : ( ) = > this . clearSelectedBlock ( ) ,
runCommand : ( command : WordsCommandName ) = > this . runCommandName ( command ) ,
runCommand : ( command : WordsCommandName ) = > this . runCommandName ( command ) ,
setCodeLanguage : ( language? : string ) = > this . setCodeLanguage ( language ) ,
setCodeLanguage : ( language? : string ) = > this . setCodeLanguage ( language ) ,
insertLink : ( href : string , title? : string ) = > this . insertLink ( href , title ) ,
insertLink : ( href : string , title? : string ) = > this . insertLink ( href , title ) ,