22import { CustomSelectionPluginKey } from '@core/selection-state.js' ;
33import { getLineHeightValueString } from '@core/super-converter/helpers.js' ;
44import { findParentNode } from '../../core/helpers/findParentNode.js' ;
5+ import { findParentNodeClosestToPos } from '../../core/helpers/findParentNodeClosestToPos.js' ;
56import { kebabCase } from '@superdoc/common' ;
67import { getUnderlineCssString } from './index.js' ;
78import { twipsToLines , twipsToPixels , halfPointToPixels } from '@converter/helpers.js' ;
@@ -308,6 +309,33 @@ export const generateLinkedStyleString = (linkedStyle, basedOnStyle, node, paren
308309 return final ;
309310} ;
310311
312+ /**
313+ * @param {import('prosemirror-model').Node } doc
314+ * @param {number } paragraphPos
315+ * @param {import('prosemirror-model').Node } paragraphNode
316+ * @returns {{ from: number; to: number } | null } Half-span [from, to) covering all text in the paragraph
317+ */
318+ const getParagraphTextBounds = ( doc , paragraphPos , paragraphNode ) => {
319+ let minPos = null ;
320+ let maxPos = null ;
321+ const innerStart = paragraphPos + 1 ;
322+ const innerEnd = paragraphPos + paragraphNode . nodeSize - 1 ;
323+ doc . nodesBetween ( innerStart , innerEnd , ( node , pos ) => {
324+ if ( node . isText ) {
325+ if ( minPos === null || pos < minPos ) minPos = pos ;
326+ maxPos = pos + node . nodeSize ;
327+ }
328+ return true ;
329+ } ) ;
330+ if ( minPos === null || maxPos === null ) return null ;
331+ return { from : minPos , to : maxPos } ;
332+ } ;
333+
334+ const applyCharacterStyleMarkToRange = ( tr , textStyleType , from , to , styleId ) => {
335+ tr . removeMark ( from , to , textStyleType ) ;
336+ tr . addMark ( from , to , textStyleType . create ( { styleId } ) ) ;
337+ } ;
338+
311339/**
312340 * Apply a linked style to a transaction
313341 * @category Helper
@@ -325,6 +353,7 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
325353
326354 let selection = tr . selection ;
327355 const state = editor . state ;
356+ const textStyleType = editor . schema . marks . textStyle ;
328357
329358 // Check for preserved selection from custom selection plugin
330359 const focusState = CustomSelectionPluginKey . getState ( state ) ;
@@ -351,16 +380,24 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
351380 } ;
352381 } ;
353382
354- // Function to clear formatting marks from text content
355- const clearFormattingMarks = ( startPos , endPos ) => {
356- tr . doc . nodesBetween ( startPos , endPos , ( node , pos ) => {
357- if ( node . isText && node . marks . length > 0 ) {
358- node . marks . forEach ( ( mark ) => {
359- if ( FORMATTING_MARK_NAMES . has ( mark . type . name ) ) {
360- tr . removeMark ( pos , pos + node . nodeSize , mark ) ;
361- }
362- } ) ;
383+ // Clear FORMATTING_MARK_NAMES only inside [rangeFrom, rangeTo), not across whole text nodes
384+ // (selection can split mid-node; removeMark must use the intersection with each text slice).
385+ const clearFormattingMarks = ( rangeFrom , rangeTo ) => {
386+ tr . doc . nodesBetween ( rangeFrom , rangeTo , ( node , pos ) => {
387+ if ( ! node . isText || node . marks . length === 0 ) {
388+ return true ;
363389 }
390+ const nodeEnd = pos + node . nodeSize ;
391+ const clearFrom = Math . max ( pos , rangeFrom ) ;
392+ const clearTo = Math . min ( nodeEnd , rangeTo ) ;
393+ if ( clearFrom >= clearTo ) {
394+ return true ;
395+ }
396+ node . marks . forEach ( ( mark ) => {
397+ if ( FORMATTING_MARK_NAMES . has ( mark . type . name ) ) {
398+ tr . removeMark ( clearFrom , clearTo , mark ) ;
399+ }
400+ } ) ;
364401 return true ;
365402 } ) ;
366403 } ;
@@ -377,7 +414,23 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
377414 }
378415 } ;
379416
380- // Handle cursor position (no selection)
417+ // Character styles: only affect the selected range (or stored marks for collapsed cursor)
418+ if ( style . type === 'character' ) {
419+ if ( ! textStyleType ) return false ;
420+ if ( from === to ) {
421+ // Collapsed cursor: set stored marks for subsequent typing
422+ const sourceMarks = tr . storedMarks ?? state . storedMarks ?? selection . $from . marks ( ) ;
423+ const filtered = sourceMarks . filter ( ( mark ) => mark . type !== textStyleType ) ;
424+ tr . setStoredMarks ( [ ...filtered , textStyleType . create ( { styleId : style . id } ) ] ) ;
425+ return true ;
426+ }
427+ clearFormattingMarks ( from , to ) ;
428+ applyCharacterStyleMarkToRange ( tr , textStyleType , from , to , style . id ) ;
429+ clearStoredFormattingMarks ( ) ;
430+ return true ;
431+ }
432+
433+ // Handle cursor position (no selection) — paragraph styles only
381434 if ( from === to ) {
382435 let pos = from ;
383436 let paragraphNode = tr . doc . nodeAt ( from ) ;
@@ -398,7 +451,29 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
398451 return true ;
399452 }
400453
401- // Handle selection spanning multiple nodes
454+ // Paragraph style + partial selection in a single paragraph: apply linked character style to range only (Word behavior)
455+ if ( style . type === 'paragraph' && textStyleType ) {
456+ const linkedCharStyleId = style . definition ?. attrs ?. link ;
457+ if ( linkedCharStyleId ) {
458+ const $fromPos = tr . doc . resolve ( from ) ;
459+ const $toPos = tr . doc . resolve ( to ) ;
460+ const startPara = findParentNodeClosestToPos ( $fromPos , ( n ) => n . type . name === 'paragraph' ) ;
461+ const endPara = findParentNodeClosestToPos ( $toPos , ( n ) => n . type . name === 'paragraph' ) ;
462+ if ( startPara && endPara && startPara . pos === endPara . pos ) {
463+ const bounds = getParagraphTextBounds ( tr . doc , startPara . pos , startPara . node ) ;
464+ const coversFullParagraphText = bounds && from <= bounds . from && to >= bounds . to ;
465+ // No text (empty / image-only): cannot do linked character range apply; use paragraph path below.
466+ if ( bounds && ! coversFullParagraphText ) {
467+ clearFormattingMarks ( from , to ) ;
468+ applyCharacterStyleMarkToRange ( tr , textStyleType , from , to , linkedCharStyleId ) ;
469+ clearStoredFormattingMarks ( ) ;
470+ return true ;
471+ }
472+ }
473+ }
474+ }
475+
476+ // Handle selection spanning multiple nodes / full paragraph(s)
402477 const paragraphPositions = [ ] ;
403478
404479 tr . doc . nodesBetween ( from , to , ( node , pos ) => {
@@ -408,6 +483,18 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
408483 return true ;
409484 } ) ;
410485
486+ // nodesBetween often skips block parents when the range only covers inline content (e.g. image-only).
487+ if ( paragraphPositions . length === 0 && from !== to ) {
488+ const seen = new Set ( ) ;
489+ const pushParagraph = ( info ) => {
490+ if ( ! info || seen . has ( info . pos ) ) return ;
491+ seen . add ( info . pos ) ;
492+ paragraphPositions . push ( { node : info . node , pos : info . pos } ) ;
493+ } ;
494+ pushParagraph ( findParentNodeClosestToPos ( tr . doc . resolve ( from ) , ( n ) => n . type . name === 'paragraph' ) ) ;
495+ pushParagraph ( findParentNodeClosestToPos ( tr . doc . resolve ( to ) , ( n ) => n . type . name === 'paragraph' ) ) ;
496+ }
497+
411498 // Apply style to all paragraphs in selection (with clean attributes and cleared marks)
412499 paragraphPositions . forEach ( ( { node, pos } ) => {
413500 // Clear formatting marks within this paragraph
0 commit comments