Skip to content

Commit 82f6e46

Browse files
committed
feat(editor): add read-only zones support for codePrefix/codeSuffix
- Add initWithContext() method for prefix/suffix initialization - Implement changeFilter to prevent edits in read-only zones - Add transactionFilter to constrain cursor to editable area - Add visual decorations with cm-readonly-zone class - Update getValue/setValue to handle editable portions correctly
1 parent 847b261 commit 82f6e46

1 file changed

Lines changed: 163 additions & 17 deletions

File tree

src/impl/CodeEditor.js

Lines changed: 163 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
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";
66
import { defaultKeymap, historyKeymap, indentMore, indentLess, undo, redo } from "@codemirror/commands";
77
import { history } from "@codemirror/commands";
88
import { 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

Comments
 (0)