@ -17,6 +17,7 @@ import type {
WordsBubbleMenuSide ,
WordsCommandName ,
WordsCommitReason ,
WordsOnUploadImage ,
WordsSelectionRect ,
WordsSelectionKind ,
WordsSlashCommandId ,
@ -61,6 +62,7 @@ import {
type WordsExportFormat ,
type WordsEditorState ,
type WordsHistory ,
type WordsImageBlock ,
type WordsImportFormat ,
type WordsMark ,
type WordsPoint ,
@ -91,6 +93,7 @@ interface WordsOpts
onValueChange : OnChangeFn < WordsDocument > | undefined ;
onSelectionChange : OnChangeFn < WordsSelection | null > | undefined ;
onValueCommit : ( ( document : WordsDocument , reason : WordsCommitReason ) = > void ) | undefined ;
onUploadImage : WordsOnUploadImage | undefined ;
} > { }
interface WordsContentOpts extends WithRefOpts { }
@ -925,6 +928,20 @@ export class WordsProvider {
return ;
}
// Image files first — Windows / macOS / Chrome ship them under
// clipboardData.files when the user copies an image from a
// browser / file manager / screenshot tool. If at least one
// image-typed file is present *and* the consumer has wired
// onUploadImage, we own the paste and drop the regular text/html
// processing entirely (mixing both produces awkward layouts).
const imageFiles = collectImageFiles ( e . clipboardData ? . files ) ;
if ( imageFiles . length > 0 && this . opts . onUploadImage . current ) {
e . preventDefault ( ) ;
this . syncSelectionFromDom ( { source : 'input' } ) ;
void this . insertImageFiles ( imageFiles , e . currentTarget ) ;
return ;
}
const plainText = e . clipboardData ? . getData ( 'text/plain' ) ? ? '' ;
const html = e . clipboardData ? . getData ( 'text/html' ) ? ? '' ;
const action = actionFromPaste ( {
@ -942,6 +959,103 @@ export class WordsProvider {
this . applyCommand ( action . command ) ;
} ;
readonly ondragover = ( e : DragEvent & { currentTarget : HTMLElement } ) = > {
// Only intercept drags carrying image files; let everything else
// (text drags, internal selection drags) follow the browser
// default so the contenteditable's native drop handling works.
if ( this . isDisabled || this . isReadonly ) return ;
if ( ! this . opts . onUploadImage . current ) return ;
if ( ! dataTransferHasImageFiles ( e . dataTransfer ) ) return ;
e . preventDefault ( ) ;
if ( e . dataTransfer ) e . dataTransfer . dropEffect = 'copy' ;
} ;
readonly ondrop = ( e : DragEvent & { currentTarget : HTMLElement } ) = > {
if ( this . isDisabled || this . isReadonly ) return ;
if ( ! this . opts . onUploadImage . current ) return ;
const imageFiles = collectImageFiles ( e . dataTransfer ? . files ) ;
if ( imageFiles . length === 0 ) return ;
e . preventDefault ( ) ;
this . syncSelectionFromDom ( { source : 'input' } ) ;
void this . insertImageFiles ( imageFiles , e . currentTarget ) ;
} ;
/ * *
* Insert N image files : each gets a blob URL + status = 'pending'
* inserted immediately ; the consumer ' s ` onUploadImage ` callback
* runs in parallel ; as each resolves we rewrite the matching
* placeholder block by ` src ` ( which is unique because blob URLs are ) .
* Failures flip the placeholder to status = 'error' and keep the blob
* URL so the local preview survives .
* /
private async insertImageFiles ( files : readonly File [ ] , target : HTMLElement ) : Promise < void > {
const upload = this . opts . onUploadImage . current ;
if ( ! upload ) return ;
const placeholders : { readonly blobUrl : string ; readonly alt : string } [ ] = [ ] ;
for ( const file of files ) {
const node = this . opts . ref . current ;
const URLClass = node ? . ownerDocument ? . defaultView ? . URL ? ? globalThis . URL ;
const blobUrl = URLClass . createObjectURL ( file ) ;
const alt = file . name . replace ( /\.[^.]+$/ , '' ) ;
placeholders . push ( { blobUrl , alt } ) ;
this . applyCommand ( {
type : 'insertImage' ,
src : blobUrl ,
alt ,
status : 'pending'
} ) ;
}
for ( const { blobUrl , alt } of placeholders ) {
try {
const file = files [ placeholders . findIndex ( ( p ) = > p . blobUrl === blobUrl ) ] ;
if ( ! file ) continue ;
const result = await upload ( file ) ;
this . replaceImageBlock ( blobUrl , {
src : result.url ,
alt : result.alt ? ? alt
} ) ;
} catch {
this . markImageBlockError ( blobUrl ) ;
}
}
// best-effort telemetry trigger; intentionally swallowed if the
// runtime hasn't been started in the current scope (tests, SSR).
try {
void this . runtime . trigger ( 'commit-save-content' , { fallbackTarget : target } ) ;
} catch {
/* noop */
}
}
/ * *
* Replace the image block whose current ` src ` is ` previousSrc ` . Used
* by the paste / drop upload flow to swap a blob URL for the final
* uploaded URL once the consumer ' s promise resolves .
* /
private replaceImageBlock (
previousSrc : string ,
patch : { readonly src : string ; readonly alt? : string }
) : void {
const next = mapImageBlocksBySrc ( this . document , previousSrc , ( block ) = > ( {
. . . block ,
src : patch.src ,
. . . ( patch . alt !== undefined ? { alt : patch.alt } : { } ) ,
status : undefined
} ) ) ;
if ( next === this . document ) return ;
this . applyCommand ( { type : 'replaceDocument' , document : next } ) ;
}
/** Flip an image-block (matched by src) into status='error'. */
private markImageBlockError ( src : string ) : void {
const next = mapImageBlocksBySrc ( this . document , src , ( block ) = > ( {
. . . block ,
status : 'error' as const
} ) ) ;
if ( next === this . document ) return ;
this . applyCommand ( { type : 'replaceDocument' , document : next } ) ;
}
readonly oncompositionstart = ( e : CompositionEvent ) = > {
this . composition = beginWordsComposition ( e . data ? ? '' ) ;
} ;
@ -1215,7 +1329,23 @@ export class WordsProvider {
( id ? this . slashMenuItems . find ( ( entry ) = > entry . id === id ) : undefined ) ? ?
this . slashMenuItems [ this . slashMenuActiveIndex ] ;
if ( ! item ) return false ;
const command = slashCommandToWordsCommand ( item ) ;
let command : WordsCommand | undefined ;
if ( item . id === 'image' ) {
// Image insert needs a runtime URL. The MVP uses the browser's
// native prompt; consumers wanting a custom dialog can hide
// the slash menu's 'image' entry (via `slashCommands` prop
// override) and trigger `applyWordsCommand({ type: 'insertImage',
// src, alt? })` from their own toolbar/dialog instead.
const node = this . opts . ref . current ;
const win = node ? . ownerDocument ? . defaultView ? ? undefined ;
const src = win ? . prompt ? . ( 'Image URL:' ) ? . trim ( ) ;
if ( ! src ) return false ;
const altRaw = win ? . prompt ? . ( 'Alt text (optional):' ) ;
const alt = altRaw ? . trim ( ) ? altRaw . trim ( ) : undefined ;
command = { type : 'insertImage' , src , alt } ;
} else {
command = slashCommandToWordsCommand ( item ) ;
}
if ( ! command ) return false ;
const selected = setWordsSelection ( this . history . present , {
@ -1563,6 +1693,8 @@ export class WordsContentProvider {
onselect : this.provider.onselect ,
onbeforeinput : this.provider.onbeforeinput ,
onpaste : this.provider.onpaste ,
ondragover : this.provider.ondragover ,
ondrop : this.provider.ondrop ,
oncompositionstart : this.provider.oncompositionstart ,
oncompositionupdate : this.provider.oncompositionupdate ,
oncompositionend : this.provider.oncompositionend
@ -2443,6 +2575,13 @@ const DEFAULT_SLASH_COMMANDS = [
command : 'check-list' ,
listKind : 'check' ,
keywords : [ 'todo' , 'task' , 'check' ]
} ,
{
id : 'image' ,
label : 'Image' ,
description : 'Insert an image from a URL' ,
command : 'insert-image' ,
keywords : [ 'img' , 'picture' , 'photo' , 'figure' ]
}
] as const satisfies readonly WordsSlashCommandItem [ ] ;
@ -2477,5 +2616,64 @@ function slashCommandToWordsCommand(item: WordsSlashCommandItem): WordsCommand |
return { type : 'toggleList' , kind : 'ordered' } ;
case 'check-list' :
return { type : 'toggleList' , kind : 'check' } ;
case 'image' :
// Image needs a runtime prompt for the URL — handled imperatively
// in commitSlashCommand. Returning undefined here lets that path
// short-circuit if the prompt resolves to an empty URL.
return undefined ;
}
}
// ── Image paste/drop helpers (F3.8) ─────────────────────────────────────
function collectImageFiles ( files : FileList | null | undefined ) : File [ ] {
if ( ! files || files . length === 0 ) return [ ] ;
const out : File [ ] = [ ] ;
for ( let i = 0 ; i < files . length ; i ++ ) {
const f = files . item ( i ) ;
if ( f && f . type . startsWith ( 'image/' ) ) out . push ( f ) ;
}
return out ;
}
function dataTransferHasImageFiles ( dt : DataTransfer | null | undefined ) : boolean {
if ( ! dt ) return false ;
// `types` is the most reliable signal during dragover (files aren't
// readable yet on dragover in most browsers — only on drop). The
// 'Files' entry in `types` means at least one file is in the drag.
if ( Array . from ( dt . types ) . includes ( 'Files' ) ) {
// items[] is readable on dragover in Chromium and exposes kind+type
// without violating the drop-target restriction. Use it when
// present to filter to image-typed files specifically.
if ( dt . items && dt . items . length > 0 ) {
for ( let i = 0 ; i < dt . items . length ; i ++ ) {
const item = dt . items [ i ] ;
if ( item . kind === 'file' && item . type . startsWith ( 'image/' ) ) return true ;
}
return false ;
}
return true ;
}
return false ;
}
/ * *
* Map all image blocks whose ` src ` matches ` previousSrc ` through
* ` update ` , returning a new document . Returns the same reference if
* no block matched ( so callers can early - exit on no - op ) .
* /
function mapImageBlocksBySrc (
document : WordsDocument ,
previousSrc : string ,
update : ( block : WordsImageBlock ) = > WordsImageBlock
) : WordsDocument {
let changed = false ;
const children = document . children . map ( ( block ) = > {
if ( block . type !== 'image' || block . src !== previousSrc ) return block ;
const next = update ( block ) ;
if ( next !== block ) changed = true ;
return next ;
} ) ;
if ( ! changed ) return document ;
return { . . . document , children } ;
}