@@ -7,6 +7,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
77import { ParseError , parseDeck } from '@/ir/parse' ;
88import { planDeck } from '@/ir/plan' ;
99import { reorderSlide } from '@/ir/source-edit' ;
10+ import { replaceHeadingOccurrence , type EditableKind } from '@/ir/text-edit' ;
1011import { lintColors } from '@/render/lint' ;
1112import { resolveTheme } from '@/render/theme-resolver' ;
1213import type { Brand , Deck , Density , Mode , ThemeRef } from '@/ir/schema' ;
@@ -183,6 +184,10 @@ export function Editor({ deckId }: Props) {
183184 setSelectedSlide ( to ) ;
184185 } , [ ] ) ;
185186
187+ const handleHeadingEdit = useCallback ( ( kind : EditableKind , index : number , nextText : string ) => {
188+ setSource ( ( s ) => replaceHeadingOccurrence ( s , kind , index , nextText ) ) ;
189+ } , [ ] ) ;
190+
186191 const handleInsert = useCallback ( ( snippet : string ) => {
187192 insertRef . current ?.( snippet ) ;
188193 } , [ ] ) ;
@@ -347,6 +352,7 @@ export function Editor({ deckId }: Props) {
347352 selectedSlide = { selectedSlide }
348353 onSelectSlide = { handleSelectSlide }
349354 onReorderSlide = { handleReorderSlide }
355+ onHeadingEdit = { handleHeadingEdit }
350356 />
351357 ) : (
352358 < div className = "editor__error" >
@@ -459,11 +465,13 @@ function PreviewStage({
459465 selectedSlide,
460466 onSelectSlide,
461467 onReorderSlide,
468+ onHeadingEdit,
462469} : {
463470 deck : Deck ;
464471 selectedSlide : number ;
465472 onSelectSlide : ( i : number ) => void ;
466473 onReorderSlide : ( from : number , to : number ) => void ;
474+ onHeadingEdit : ( kind : EditableKind , index : number , nextText : string ) => void ;
467475} ) {
468476 const total = deck . slides . length ;
469477 const safeIndex = Math . min ( Math . max ( selectedSlide , 0 ) , Math . max ( total - 1 , 0 ) ) ;
@@ -488,10 +496,88 @@ function PreviewStage({
488496 return ( ) => window . removeEventListener ( 'keydown' , onKey ) ;
489497 } , [ safeIndex , total , onSelectSlide ] ) ;
490498
499+ const slideRef = useRef < HTMLDivElement > ( null ) ;
500+
501+ const beginEdit = useCallback (
502+ ( el : HTMLElement ) => {
503+ const tag = el . tagName . toLowerCase ( ) ;
504+ if ( ! / ^ h [ 1 - 4 ] $ / . test ( tag ) ) return ;
505+ // Compute occurrence index across the FULL deck, not just the visible
506+ // slide. We render slides individually, so we have to look through deck.
507+ const kind = tag as EditableKind ;
508+ const targetText = el . textContent ?? '' ;
509+ let occurrence = 0 ;
510+ const targetLevel = Number ( kind . slice ( 1 ) ) ;
511+ outer: for ( let s = 0 ; s < deck . slides . length ; s ++ ) {
512+ const blocks = deck . slides [ s ] . blocks ;
513+ const stack : typeof blocks = [ ...blocks ] ;
514+ while ( stack . length > 0 ) {
515+ const b = stack . shift ( ) ! ;
516+ if ( b . type === 'heading' ) {
517+ if ( b . level === targetLevel ) {
518+ if ( s === safeIndex && b . text === targetText ) {
519+ break outer;
520+ }
521+ occurrence ++ ;
522+ }
523+ } else if ( b . type === 'box' ) {
524+ stack . unshift ( ...b . children ) ;
525+ } else if ( b . type === 'columns' ) {
526+ stack . unshift ( ...b . columns . flat ( ) ) ;
527+ } else if ( b . type === 'grid' || b . type === 'cell' ) {
528+ stack . unshift ( ...b . children ) ;
529+ }
530+ }
531+ }
532+ el . contentEditable = 'true' ;
533+ el . classList . add ( 'preview-editable' ) ;
534+ el . focus ( ) ;
535+ const range = document . createRange ( ) ;
536+ range . selectNodeContents ( el ) ;
537+ const sel = window . getSelection ( ) ;
538+ sel ?. removeAllRanges ( ) ;
539+ sel ?. addRange ( range ) ;
540+
541+ const finish = ( ) => {
542+ el . removeEventListener ( 'blur' , finish ) ;
543+ el . removeEventListener ( 'keydown' , onKey ) ;
544+ el . contentEditable = 'false' ;
545+ el . classList . remove ( 'preview-editable' ) ;
546+ const next = ( el . textContent ?? '' ) . trim ( ) ;
547+ if ( next && next !== targetText ) onHeadingEdit ( kind , occurrence , next ) ;
548+ } ;
549+ const onKey = ( ke : KeyboardEvent ) => {
550+ if ( ke . key === 'Enter' && ! ke . shiftKey ) {
551+ ke . preventDefault ( ) ;
552+ el . blur ( ) ;
553+ } else if ( ke . key === 'Escape' ) {
554+ ke . preventDefault ( ) ;
555+ el . textContent = targetText ;
556+ el . blur ( ) ;
557+ }
558+ } ;
559+ el . addEventListener ( 'blur' , finish ) ;
560+ el . addEventListener ( 'keydown' , onKey ) ;
561+ } ,
562+ [ deck , safeIndex , onHeadingEdit ] ,
563+ ) ;
564+
565+ const onDoubleClick = useCallback (
566+ ( e : React . MouseEvent < HTMLDivElement > ) => {
567+ const t = e . target as HTMLElement ;
568+ const heading = t . closest ( 'h1, h2, h3, h4' ) as HTMLElement | null ;
569+ if ( heading && slideRef . current ?. contains ( heading ) ) {
570+ e . preventDefault ( ) ;
571+ beginEdit ( heading ) ;
572+ }
573+ } ,
574+ [ beginEdit ] ,
575+ ) ;
576+
491577 return (
492578 < div className = "stage" >
493579 < div className = "stage__viewport" >
494- < div className = "stage__slide" >
580+ < div className = "stage__slide" ref = { slideRef } onDoubleClick = { onDoubleClick } >
495581 < DeckRenderer deck = { visibleDeck } />
496582 </ div >
497583 </ div >
0 commit comments