11/**
22 * CodeEditor - CodeMirror 6 wrapper with Emmet support
33 */
4- import { EditorState , Prec } from "@codemirror/state" ;
5- import { EditorView , keymap , placeholder } from "@codemirror/view" ;
4+ import { EditorState , EditorSelection , Prec , StateField , Compartment } from "@codemirror/state" ;
5+ import { EditorView , keymap , placeholder , Decoration } from "@codemirror/view" ;
66import { defaultKeymap , historyKeymap , indentMore , indentLess , undo , redo } from "@codemirror/commands" ;
77import { history } from "@codemirror/commands" ;
88import { html } from "@codemirror/lang-html" ;
@@ -146,25 +146,146 @@ export class CodeEditor {
146146 this . mode = options . mode || "css" ;
147147 this . section = options . section || null ;
148148 this . onChange = options . onChange || ( ( ) => { } ) ;
149+ // Read-only zones support
150+ this . prefixLength = 0 ;
151+ this . suffixLength = 0 ;
152+ this . currentPrefix = "" ;
153+ this . currentSuffix = "" ;
154+ this . readOnlyCompartment = new Compartment ( ) ;
149155 }
150156
151157 /**
152- * Initialize the editor
158+ * Initialize the editor (backwards compatible wrapper)
153159 */
154160 init ( initialValue = "" ) {
161+ return this . initWithContext ( "" , initialValue , "" ) ;
162+ }
163+
164+ /**
165+ * Initialize the editor with read-only prefix/suffix zones
166+ * @param {string } prefix - Read-only prefix text (e.g., ".card {\n ")
167+ * @param {string } initialValue - Editable user code
168+ * @param {string } suffix - Read-only suffix text (e.g., "\n}")
169+ */
170+ initWithContext ( prefix = "" , initialValue = "" , suffix = "" ) {
155171 // Clear container
156172 this . container . innerHTML = "" ;
157173
174+ // Store prefix/suffix for re-initialization (e.g., when mode changes)
175+ this . currentPrefix = prefix ;
176+ this . currentSuffix = suffix ;
177+ this . prefixLength = prefix . length ;
178+ this . suffixLength = suffix . length ;
179+
180+ const fullDoc = prefix + initialValue + suffix ;
181+
158182 // Get language extension based on mode
159183 const langExtension = this . mode === "html" ? html ( ) : css ( ) ;
160184
185+ // Create read-only zones decorations
186+ const readOnlyMark = Decoration . mark ( { class : "cm-readonly-zone" } ) ;
187+
188+ // StateField to track and provide decorations for read-only zones
189+ const readOnlyDecorations = StateField . define ( {
190+ create : ( state ) => {
191+ const decorations = [ ] ;
192+ if ( this . prefixLength > 0 ) {
193+ decorations . push ( readOnlyMark . range ( 0 , this . prefixLength ) ) ;
194+ }
195+ if ( this . suffixLength > 0 ) {
196+ const suffixStart = state . doc . length - this . suffixLength ;
197+ decorations . push ( readOnlyMark . range ( suffixStart , state . doc . length ) ) ;
198+ }
199+ return Decoration . set ( decorations ) ;
200+ } ,
201+ update : ( decorations , tr ) => {
202+ if ( ! tr . docChanged ) return decorations ;
203+ // Recalculate decorations after document changes
204+ const newDecorations = [ ] ;
205+ if ( this . prefixLength > 0 ) {
206+ newDecorations . push ( readOnlyMark . range ( 0 , this . prefixLength ) ) ;
207+ }
208+ if ( this . suffixLength > 0 ) {
209+ const suffixStart = tr . state . doc . length - this . suffixLength ;
210+ newDecorations . push ( readOnlyMark . range ( suffixStart , tr . state . doc . length ) ) ;
211+ }
212+ return Decoration . set ( newDecorations ) ;
213+ } ,
214+ provide : ( f ) => EditorView . decorations . from ( f )
215+ } ) ;
216+
217+ // Change filter to prevent edits in read-only zones
218+ const readOnlyFilter = EditorState . changeFilter . of ( ( tr ) => {
219+ // If no prefix/suffix, allow all changes
220+ if ( this . prefixLength === 0 && this . suffixLength === 0 ) {
221+ return true ;
222+ }
223+
224+ const prefixEnd = this . prefixLength ;
225+ const suffixStart = tr . startState . doc . length - this . suffixLength ;
226+
227+ // Check all change ranges - allow only changes within [prefixEnd, suffixStart]
228+ let blocked = false ;
229+ tr . changes . iterChangedRanges ( ( fromA , toA ) => {
230+ // Block if change starts in prefix zone
231+ if ( fromA < prefixEnd ) {
232+ blocked = true ;
233+ }
234+ // Block if change extends into suffix zone
235+ if ( toA > suffixStart ) {
236+ blocked = true ;
237+ }
238+ } ) ;
239+
240+ return ! blocked ;
241+ } ) ;
242+
243+ // Transaction filter to constrain cursor/selection to editable area
244+ const cursorFilter = EditorState . transactionFilter . of ( ( tr ) => {
245+ // If no prefix/suffix, no constraints needed
246+ if ( this . prefixLength === 0 && this . suffixLength === 0 ) {
247+ return tr ;
248+ }
249+
250+ const prefixEnd = this . prefixLength ;
251+ const suffixStart = tr . newDoc . length - this . suffixLength ;
252+
253+ // Check if selection needs adjustment
254+ const selection = tr . newSelection ;
255+ let needsAdjustment = false ;
256+
257+ for ( const range of selection . ranges ) {
258+ if ( range . from < prefixEnd || range . to > suffixStart ) {
259+ needsAdjustment = true ;
260+ break ;
261+ }
262+ }
263+
264+ if ( ! needsAdjustment ) {
265+ return tr ;
266+ }
267+
268+ // Clamp selection to editable area
269+ const newRanges = selection . ranges . map ( ( range ) => {
270+ const from = Math . max ( prefixEnd , Math . min ( suffixStart , range . from ) ) ;
271+ const to = Math . max ( prefixEnd , Math . min ( suffixStart , range . to ) ) ;
272+ return EditorSelection . range ( from , to ) ;
273+ } ) ;
274+
275+ return [ tr , { selection : EditorSelection . create ( newRanges , selection . mainIndex ) } ] ;
276+ } ) ;
277+
161278 // Build extensions array
162279 const extensions = [
163280 langExtension ,
164281 getEditorTheme ( this . section ) ,
165282 editorTheme ,
166283 // History for undo/redo
167284 history ( ) ,
285+ // Read-only zones (decorations, change filter, and cursor constraint)
286+ readOnlyDecorations ,
287+ readOnlyFilter ,
288+ cursorFilter ,
168289 // Emmet abbreviation tracking
169290 abbreviationTracker ( ) ,
170291 // High priority keymap for Emmet
@@ -184,20 +305,21 @@ export class CodeEditor {
184305 } ) ,
185306 EditorView . updateListener . of ( ( update ) => {
186307 if ( update . docChanged ) {
187- this . onChange ( this . getValue ( ) ) ;
308+ // Report only the editable portion to the onChange handler
309+ this . onChange ( this . getEditableValue ( ) ) ;
188310 }
189311 } ) ,
190312 EditorView . lineWrapping
191313 ] ;
192314
193- // Add placeholder if provided
194- if ( this . options . placeholder ) {
315+ // Add placeholder if provided (only makes sense when no prefix/suffix)
316+ if ( this . options . placeholder && this . prefixLength === 0 && this . suffixLength === 0 ) {
195317 extensions . push ( placeholder ( this . options . placeholder ) ) ;
196318 }
197319
198320 // Create editor state
199321 const state = EditorState . create ( {
200- doc : initialValue ,
322+ doc : fullDoc ,
201323 extensions
202324 } ) ;
203325
@@ -207,36 +329,60 @@ export class CodeEditor {
207329 parent : this . container
208330 } ) ;
209331
332+ // Position cursor at start of editable area
333+ if ( this . prefixLength > 0 ) {
334+ this . view . dispatch ( {
335+ selection : { anchor : this . prefixLength }
336+ } ) ;
337+ }
338+
210339 return this ;
211340 }
212341
213342 /**
214- * Get current editor value
343+ * Get current full editor value (including prefix/suffix)
215344 */
216345 getValue ( ) {
217346 return this . view ? this . view . state . doc . toString ( ) : "" ;
218347 }
219348
220349 /**
221- * Set editor value (preserves history)
350+ * Get only the editable portion (excluding prefix/suffix)
351+ */
352+ getEditableValue ( ) {
353+ if ( ! this . view ) return "" ;
354+ const fullText = this . view . state . doc . toString ( ) ;
355+ const editableEnd = fullText . length - this . suffixLength ;
356+ return fullText . slice ( this . prefixLength , editableEnd ) ;
357+ }
358+
359+ /**
360+ * Set editor value in the editable zone only (preserves history)
222361 */
223362 setValue ( value ) {
224363 if ( ! this . view ) return ;
225364
365+ // Only replace the editable portion
366+ const editableStart = this . prefixLength ;
367+ const editableEnd = this . view . state . doc . length - this . suffixLength ;
368+
226369 this . view . dispatch ( {
227370 changes : {
228- from : 0 ,
229- to : this . view . state . doc . length ,
371+ from : editableStart ,
372+ to : editableEnd ,
230373 insert : value
231374 }
232375 } ) ;
233376 }
234377
235378 /**
236379 * Set editor value and clear history (for lesson switching)
380+ * @param {string } value - The editable user code (not including prefix/suffix)
381+ * @param {string } prefix - Optional read-only prefix
382+ * @param {string } suffix - Optional read-only suffix
237383 */
238- setValueAndClearHistory ( value ) {
239- this . init ( value ) ;
384+ setValueAndClearHistory ( value , prefix = "" , suffix = "" ) {
385+ this . initWithContext ( prefix , value , suffix ) ;
240386 }
241387
242388 /**
@@ -246,8 +392,8 @@ export class CodeEditor {
246392 if ( this . mode === mode ) return ;
247393
248394 this . mode = mode ;
249- const currentValue = this . getValue ( ) ;
250- this . init ( currentValue ) ;
395+ const editableValue = this . getEditableValue ( ) ;
396+ this . initWithContext ( this . currentPrefix , editableValue , this . currentSuffix ) ;
251397 }
252398
253399 /**
@@ -257,8 +403,8 @@ export class CodeEditor {
257403 if ( this . section === section ) return ;
258404
259405 this . section = section ;
260- const currentValue = this . getValue ( ) ;
261- this . init ( currentValue ) ;
406+ const editableValue = this . getEditableValue ( ) ;
407+ this . initWithContext ( this . currentPrefix , editableValue , this . currentSuffix ) ;
262408 }
263409
264410 /**
0 commit comments