@@ -580,6 +580,7 @@ export class PresentationEditor extends EventEmitter {
580580 #viewportHost: HTMLElement ;
581581 #painterHost: HTMLElement ;
582582 #selectionOverlay: HTMLElement ;
583+ #permissionOverlay: HTMLElement | null = null ;
583584 #hiddenHost: HTMLElement ;
584585 #layoutOptions: LayoutEngineOptions ;
585586 #layoutState: LayoutState = { blocks : [ ] , measures : [ ] , layout : null , bookmarks : new Map ( ) } ;
@@ -763,6 +764,17 @@ export class PresentationEditor extends EventEmitter {
763764 } ) ;
764765 this . #domIndexObserverManager. setup ( ) ;
765766 this . #selectionSync. on ( 'render' , ( ) => this . #updateSelection( ) ) ;
767+ this . #selectionSync. on ( 'render' , ( ) => this . #updatePermissionOverlay( ) ) ;
768+
769+ this . #permissionOverlay = doc . createElement ( 'div' ) ;
770+ this . #permissionOverlay. className = 'presentation-editor__permission-overlay' ;
771+ Object . assign ( this . #permissionOverlay. style , {
772+ position : 'absolute' ,
773+ inset : '0' ,
774+ pointerEvents : 'none' ,
775+ zIndex : '5' ,
776+ } ) ;
777+ this . #viewportHost. appendChild ( this . #permissionOverlay) ;
766778
767779 // Create dual-layer overlay structure
768780 // Container holds both remote (below) and local (above) layers
@@ -862,9 +874,9 @@ export class PresentationEditor extends EventEmitter {
862874 const normalizedEditorProps = {
863875 ...( editorOptions . editorProps ?? { } ) ,
864876 editable : ( ) => {
865- // Hidden editor respects documentMode for plugin compatibility
866- // but remains visually/interactively inert (handled by hidden container CSS)
867- return this . #documentMode !== 'viewing' ;
877+ // Hidden editor respects documentMode for plugin compatibility,
878+ // but permission ranges may temporarily re-enable editing.
879+ return ! this . #isViewLocked ( ) ;
868880 } ,
869881 } ;
870882 try {
@@ -1358,6 +1370,7 @@ export class PresentationEditor extends EventEmitter {
13581370 this . #pendingDocChange = true ;
13591371 this . #scheduleRerender( ) ;
13601372 }
1373+ this . #updatePermissionOverlay( ) ;
13611374 }
13621375
13631376 #syncDocumentModeClass( ) {
@@ -3083,7 +3096,7 @@ export class PresentationEditor extends EventEmitter {
30833096 win as Window ,
30843097 this . #visibleHost,
30853098 ( ) => this . #getActiveDomTarget( ) ,
3086- ( ) => this . #documentMode !== 'viewing' ,
3099+ ( ) => ! this . #isViewLocked ( ) ,
30873100 ) ;
30883101 this . #inputBridge. bind ( ) ;
30893102 }
@@ -4257,7 +4270,7 @@ export class PresentationEditor extends EventEmitter {
42574270 this . #dragUsedPageNotMountedFallback = false ;
42584271 return ;
42594272 }
4260- if ( this . #session. mode !== 'body' || this . #documentMode === 'viewing' ) {
4273+ if ( this . #session. mode !== 'body' || this . #isViewLocked ( ) ) {
42614274 this . #dragLastPointer = null ;
42624275 this . #dragLastRawHit = null ;
42634276 this . #dragUsedPageNotMountedFallback = false ;
@@ -4665,6 +4678,7 @@ export class PresentationEditor extends EventEmitter {
46654678 this . #epochMapper. onLayoutComplete ( layoutEpoch ) ;
46664679 this . #selectionSync. onLayoutComplete ( layoutEpoch ) ;
46674680 layoutCompleted = true ;
4681+ this . #updatePermissionOverlay( ) ;
46684682
46694683 // Reset error state on successful layout
46704684 this . #layoutError = null ;
@@ -4773,7 +4787,7 @@ export class PresentationEditor extends EventEmitter {
47734787 }
47744788
47754789 // In viewing mode, don't render caret or selection highlights
4776- if ( this . #documentMode === 'viewing' ) {
4790+ if ( this . #isViewLocked ( ) ) {
47774791 try {
47784792 this . #localSelectionLayer. innerHTML = '' ;
47794793 } catch ( error ) {
@@ -4877,6 +4891,87 @@ export class PresentationEditor extends EventEmitter {
48774891 }
48784892 }
48794893
4894+ /**
4895+ * Updates the permission overlay (w:permStart/w:permEnd) to match the current editor permission ranges.
4896+ *
4897+ * This method is called after layout completes to ensure permission overlay
4898+ * is based on stable permission ranges data.
4899+ */
4900+ #updatePermissionOverlay( ) {
4901+ const overlay = this . #permissionOverlay;
4902+ if ( ! overlay ) {
4903+ return ;
4904+ }
4905+
4906+ if ( this . #session. mode !== 'body' ) {
4907+ overlay . innerHTML = '' ;
4908+ return ;
4909+ }
4910+
4911+ const permissionStorage = ( this . #editor as Editor & { storage ?: Record < string , any > } ) ?. storage ?. permissionRanges ;
4912+ const ranges : Array < { from : number ; to : number } > = permissionStorage ?. ranges ?? [ ] ;
4913+ const shouldRender = ranges . length > 0 ;
4914+
4915+ if ( ! shouldRender ) {
4916+ overlay . innerHTML = '' ;
4917+ return ;
4918+ }
4919+
4920+ const layout = this . #layoutState. layout ;
4921+ if ( ! layout ) {
4922+ overlay . innerHTML = '' ;
4923+ return ;
4924+ }
4925+
4926+ const docEpoch = this . #epochMapper. getCurrentEpoch ( ) ;
4927+ // The visible layout DOM does not match the current document state.
4928+ // Avoid rendering a "best effort" permission overlay that would drift.
4929+ if ( this . #layoutEpoch < docEpoch ) {
4930+ return ;
4931+ }
4932+
4933+ const pageHeight = this . #getBodyPageHeight( ) ;
4934+ const pageGap = layout . pageGap ?? this . #getEffectivePageGap( ) ;
4935+ const fragment = overlay . ownerDocument ?. createDocumentFragment ( ) ;
4936+ if ( ! fragment ) {
4937+ overlay . innerHTML = '' ;
4938+ return ;
4939+ }
4940+
4941+ ranges . forEach ( ( { from, to } ) => {
4942+ const rects = this . #computeSelectionRectsFromDom( from , to ) ;
4943+ if ( ! rects ?. length ) {
4944+ return ;
4945+ }
4946+ rects . forEach ( ( rect ) => {
4947+ const pageLocalY = rect . y - rect . pageIndex * ( pageHeight + pageGap ) ;
4948+ const coords = this . #convertPageLocalToOverlayCoords( rect . pageIndex , rect . x , pageLocalY ) ;
4949+ if ( ! coords ) {
4950+ return ;
4951+ }
4952+ const highlight = overlay . ownerDocument ?. createElement ( 'div' ) ;
4953+ if ( ! highlight ) {
4954+ return ;
4955+ }
4956+ highlight . className = 'presentation-editor__permission-highlight' ;
4957+ Object . assign ( highlight . style , {
4958+ position : 'absolute' ,
4959+ left : `${ coords . x } px` ,
4960+ top : `${ coords . y } px` ,
4961+ width : `${ Math . max ( 1 , rect . width ) } px` ,
4962+ height : `${ Math . max ( 1 , rect . height ) } px` ,
4963+ borderRadius : '2px' ,
4964+ pointerEvents : 'none' ,
4965+ zIndex : 1 ,
4966+ } ) ;
4967+ fragment . appendChild ( highlight ) ;
4968+ } ) ;
4969+ } ) ;
4970+
4971+ overlay . innerHTML = '' ;
4972+ overlay . appendChild ( fragment ) ;
4973+ }
4974+
48804975 #resolveLayoutOptions( blocks : FlowBlock [ ] | undefined , sectionMetadata : SectionMetadata [ ] ) {
48814976 const defaults = this . #computeDefaultLayoutDefaults( ) ;
48824977 const firstSection = blocks ?. find (
@@ -5873,7 +5968,7 @@ export class PresentationEditor extends EventEmitter {
58735968 }
58745969
58755970 #validateHeaderFooterEditPermission( ) : { allowed : boolean ; reason ?: string } {
5876- if ( this . #documentMode === 'viewing' ) {
5971+ if ( this . #isViewLocked ( ) ) {
58775972 return { allowed : false , reason : 'documentMode' } ;
58785973 }
58795974 if ( ! this . #editor. isEditable ) {
@@ -6905,6 +7000,19 @@ export class PresentationEditor extends EventEmitter {
69057000 this . #errorBannerMessage = null ;
69067001 }
69077002
7003+ /**
7004+ * Determines whether the current viewing mode should block edits.
7005+ * When documentMode is viewing but the active editor has been toggled
7006+ * back to editable (e.g. permission ranges), we treat the view as editable.
7007+ */
7008+ #isViewLocked( ) : boolean {
7009+ if ( this . #documentMode !== 'viewing' ) return false ;
7010+ const hasPermissionOverride = ! ! ( this . #editor as Editor & { storage ?: Record < string , any > } ) ?. storage
7011+ ?. permissionRanges ?. hasAllowedRanges ;
7012+ if ( hasPermissionOverride ) return false ;
7013+ return this . #documentMode === 'viewing' ;
7014+ }
7015+
69087016 /**
69097017 * Applies vertical alignment and font scaling to layout DOM elements for subscript/superscript rendering.
69107018 *
0 commit comments