codemirror-config.js (14.2 KB)


  1 import { ZINE_BOILERPLATE, getBoilerplateCursorPos } from './constants.js';
  2 import { loadContent, loadEditorSettings, saveEditorSetting } from './storage.js';
  3 import { getAvailableFontFamilies } from './font-registry.js';
  4 
  5 let lineNumbersCompartment;
  6 let lineWrappingCompartment;
  7 let showLineNumbers = false;
  8 let enableLineWrapping = false;
  9 
 10 function compareNatural(a, b) {
 11 	return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
 12 }
 13 
 14 const fontFamilies = getAvailableFontFamilies().sort(compareNatural);
 15 let fontPickerState = null; // { selectedIndex, originalTail, fonts }
 16 let applyingFont = false; // suppresses the filter refresh our own edits would trigger
 17 let updatePreviewCb = null;
 18 
 19 function isInCSSContext(state, pos) {
 20 	const before = state.doc.sliceString(0, pos);
 21 
 22 	// Inside an open <style> block?
 23 	const styleOpen = before.lastIndexOf('<style');
 24 	if (styleOpen !== -1 && before.lastIndexOf('</style') < styleOpen) {
 25 		const tagEnd = before.indexOf('>', styleOpen);
 26 		if (tagEnd !== -1) return true;
 27 	}
 28 
 29 	// Inside an unterminated style="..." attribute of the tag we're still in?
 30 	const tagOpen = before.lastIndexOf('<');
 31 	if (tagOpen === -1) return false;
 32 	const tagText = before.slice(tagOpen);
 33 	if (tagText.includes('>')) return false;
 34 	return /style\s*=\s*(['"])(?:(?!\1)[\s\S])*$/i.test(tagText);
 35 }
 36 
 37 function getFontValueRange(state) {
 38 	const pos = state.selection.main.head;
 39 	const line = state.doc.lineAt(pos);
 40 	const text = line.text;
 41 	const propMatch = text.match(/font-family:/i);
 42 	if (!propMatch) return null;
 43 	// anchorPos is right after "font-family:" (dropdown anchors here)
 44 	const anchorPos = line.from + propMatch.index + propMatch[0].length;
 45 	// valueStart skips any whitespace after the colon
 46 	const afterColon = text.slice(propMatch.index + propMatch[0].length);
 47 	const leadingSpace = afterColon.match(/^\s*/)[0].length;
 48 	const valueStart = anchorPos + leadingSpace;
 49 	// A quoted value runs to its closing quote; an unquoted one stops at the
 50 	// first ; or " (the latter ends an inline style="..." attribute)
 51 	const afterValue = text.slice(valueStart - line.from);
 52 	const endMatch = afterValue.match(/^(?:'[^']*'?|"[^"]*"?|[^;"']*);?/);
 53 	const valueEnd = valueStart + (endMatch ? endMatch[0].length : 0);
 54 	return { from: valueStart, to: valueEnd, anchorPos };
 55 }
 56 
 57 // Everything the user typed after "font-family:", including leading whitespace
 58 function getTypedTail(state) {
 59 	const range = getFontValueRange(state);
 60 	return range ? state.doc.sliceString(range.anchorPos, range.to) : '';
 61 }
 62 
 63 function isQuoteClosed(typedTail) {
 64 	const value = typedTail.trim().replace(/;$/, '').trim();
 65 	const quote = value[0];
 66 	return (quote === "'" || quote === '"') && value.length > 1 && value.endsWith(quote);
 67 }
 68 
 69 function matchingFonts(typedTail) {
 70 	const query = typedTail.replace(/[;'"]/g, '').trim().toLowerCase();
 71 	if (!query) return fontFamilies;
 72 	return fontFamilies.filter(font => font.toLowerCase().startsWith(query));
 73 }
 74 
 75 function replaceFontValue(view, from, to, text, cursor) {
 76 	applyingFont = true;
 77 	view.dispatch({
 78 		changes: { from, to, insert: text },
 79 		selection: { anchor: cursor }
 80 	});
 81 	applyingFont = false;
 82 	// Immediate preview (bypass debounce)
 83 	clearTimeout(window.updateTimer);
 84 	if (updatePreviewCb) updatePreviewCb(view.state.doc.toString());
 85 }
 86 
 87 function applyFont(view, fontName) {
 88 	const range = getFontValueRange(view.state);
 89 	if (!range) return;
 90 	const needsSpace = range.from === range.anchorPos;
 91 	const text = (needsSpace ? ' ' : '') + `'${fontName}';`;
 92 	replaceFontValue(view, range.from, range.to, text, range.from + text.length);
 93 }
 94 
 95 // Put back whatever the user had typed before they arrowed into the list
 96 function restoreTypedValue(view) {
 97 	const range = getFontValueRange(view.state);
 98 	if (!range) return;
 99 	const tail = fontPickerState.originalTail;
100 	replaceFontValue(view, range.anchorPos, range.to, tail, range.anchorPos + tail.length);
101 }
102 
103 // Open, re-filter, or dismiss the picker in response to the user's own typing
104 function refreshFontPicker(view) {
105 	if (applyingFont) return;
106 	const range = getFontValueRange(view.state);
107 	const pos = view.state.selection.main.head;
108 	if (!range || pos < range.anchorPos || pos > range.to || !isInCSSContext(view.state, range.anchorPos)) {
109 		closeFontPicker();
110 		return;
111 	}
112 	const typedTail = view.state.doc.sliceString(range.anchorPos, range.to);
113 	if (isQuoteClosed(typedTail)) {
114 		closeFontPicker();
115 		return;
116 	}
117 	const fonts = matchingFonts(typedTail);
118 	if (fonts.length === 0) {
119 		closeFontPicker();
120 		return;
121 	}
122 	fontPickerState = { selectedIndex: -1, originalTail: '', fonts };
123 	renderFontPicker(view);
124 }
125 
126 export function closeFontPicker() {
127 	fontPickerState = null;
128 	document.querySelector('.font-picker-dropdown')?.remove();
129 }
130 
131 function renderFontPicker(view) {
132 	if (!fontPickerState) return;
133 
134 	let dropdown = document.querySelector('.font-picker-dropdown');
135 	if (!dropdown) {
136 		dropdown = document.createElement('div');
137 		dropdown.className = 'font-picker-dropdown';
138 		document.getElementById('editor').appendChild(dropdown);
139 	}
140 
141 	// Position anchored to right after "font-family:"
142 	const range = getFontValueRange(view.state);
143 	const anchorCoords = range && view.coordsAtPos(range.anchorPos);
144 	if (anchorCoords) {
145 		const editorRect = view.dom.getBoundingClientRect();
146 		dropdown.style.left = `${anchorCoords.left - editorRect.left}px`;
147 		dropdown.style.top = `${anchorCoords.bottom - editorRect.top + 4}px`;
148 	}
149 
150 	dropdown.innerHTML = '';
151 	const ul = document.createElement('ul');
152 	fontPickerState.fonts.forEach((font, i) => {
153 		const li = document.createElement('li');
154 		li.textContent = font;
155 		if (i === fontPickerState.selectedIndex) li.classList.add('selected');
156 		li.addEventListener('mousedown', (e) => {
157 			e.preventDefault();
158 			if (fontPickerState.selectedIndex === -1) {
159 				fontPickerState.originalTail = getTypedTail(view.state);
160 			}
161 			fontPickerState.selectedIndex = i;
162 			applyFont(view, font);
163 			closeFontPicker();
164 		});
165 		ul.appendChild(li);
166 	});
167 	dropdown.appendChild(ul);
168 
169 	const selectedLi = ul.children[fontPickerState.selectedIndex];
170 	if (selectedLi) selectedLi.scrollIntoView({ block: 'nearest' });
171 	else ul.scrollTop = 0;
172 }
173 
174 function cycleFontPickerSelection(view, delta) {
175 	if (!fontPickerState) return false;
176 	const fonts = fontPickerState.fonts;
177 	if (fontPickerState.selectedIndex === -1) {
178 		fontPickerState.originalTail = getTypedTail(view.state);
179 	}
180 	if (fonts.length === 1) {
181 		applyFont(view, fonts[0]);
182 		closeFontPicker();
183 		return true;
184 	}
185 	const last = fonts.length - 1;
186 	const current = fontPickerState.selectedIndex;
187 	fontPickerState.selectedIndex = current === -1
188 		? (delta > 0 ? 0 : last)
189 		: (current + delta + fonts.length) % fonts.length;
190 	applyFont(view, fonts[fontPickerState.selectedIndex]);
191 	renderFontPicker(view);
192 	return true;
193 }
194 
195 function moveFontPickerSelection(view, delta) {
196 	if (!fontPickerState) return false;
197 	const newIndex = fontPickerState.selectedIndex + delta;
198 	if (newIndex < -1 || newIndex >= fontPickerState.fonts.length) return true;
199 	if (fontPickerState.selectedIndex === -1) {
200 		fontPickerState.originalTail = getTypedTail(view.state);
201 	}
202 	fontPickerState.selectedIndex = newIndex;
203 	if (newIndex === -1) {
204 		restoreTypedValue(view);
205 	} else {
206 		applyFont(view, fontPickerState.fonts[newIndex]);
207 	}
208 	renderFontPicker(view);
209 	return true;
210 }
211 
212 export function toggleLineNumbers(editorView) {
213 	const {lineNumbers} = window.CodeMirror;
214 	showLineNumbers = !showLineNumbers;
215 	saveEditorSetting('zine-editor-line-numbers', showLineNumbers);
216 	editorView.dispatch({
217 		effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : [])
218 	});
219 }
220 
221 function createLineWrappingExtension() {
222 	const {EditorView, Decoration} = window.CodeMirror;
223 
224 	return [
225 		EditorView.lineWrapping,
226 		EditorView.decorations.of((view) => {
227 			const decorations = [];
228 			for (let {from, to} of view.visibleRanges) {
229 				for (let pos = from; pos <= to;) {
230 					const line = view.state.doc.lineAt(pos);
231 					const lineText = line.text;
232 					let indentChars = 0;
233 					for (let i = 0; i < lineText.length; i++) {
234 						if (lineText[i] === '\t') {
235 							indentChars += 2;
236 						} else if (lineText[i] === ' ') {
237 							indentChars += 1;
238 						} else {
239 							break;
240 						}
241 					}
242 					if (indentChars > 0) {
243 						const indentDecoration = Decoration.line({
244 							attributes: {
245 								style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);`
246 							}
247 						});
248 						decorations.push(indentDecoration.range(line.from));
249 					}
250 					pos = line.to + 1;
251 				}
252 			}
253 			return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none;
254 		}),
255 	];
256 }
257 
258 export function toggleLineWrapping(editorView) {
259 	enableLineWrapping = !enableLineWrapping;
260 	saveEditorSetting('zine-editor-line-wrapping', enableLineWrapping);
261 	const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : [];
262 	editorView.dispatch({
263 		effects: lineWrappingCompartment.reconfigure(lineWrappingExtension)
264 	});
265 }
266 
267 // Initialize CodeMirror
268 export async function initializeCodeMirror(saveToStorageCallback, updatePreviewCallback) {
269 	if (!window.CodeMirror) {
270 		setTimeout(() => initializeCodeMirror(saveToStorageCallback, updatePreviewCallback), 100);
271 		return;
272 	}
273 
274 	updatePreviewCb = updatePreviewCallback;
275 
276 	const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, history, closeBrackets, search, searchKeymap, lineNumbers} = window.CodeMirror;
277 
278 	const customPhrases = EditorState.phrases.of({
279 		"Find": "Find..."
280 	});
281 
282 	const { content: savedContent, isBoilerplate } = await loadContent();
283 	const settings = loadEditorSettings();
284 	showLineNumbers = settings.showLineNumbers;
285 	enableLineWrapping = settings.enableLineWrapping;
286 
287 	lineNumbersCompartment = new Compartment();
288 	lineWrappingCompartment = new Compartment();
289 
290 	const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : [];
291 
292 	const stateConfig = {
293 		doc: savedContent,
294 		extensions: [
295 			customPhrases,
296 			history(),
297 			search(),
298 			closeBrackets(),
299 			keymap.of([
300 				// Font picker keys (only active when picker is open)
301 				{key: "ArrowDown", run: (view) => moveFontPickerSelection(view, 1)},
302 				{key: "ArrowUp", run: (view) => moveFontPickerSelection(view, -1)},
303 				// Tab drives the picker when it's open; falls through to indentWithTab otherwise
304 				{key: "Tab", run: (view) => cycleFontPickerSelection(view, 1)},
305 				{key: "Shift-Tab", run: (view) => cycleFontPickerSelection(view, -1)},
306 				{key: "Enter", run: () => {
307 					if (!fontPickerState) return false;
308 					closeFontPicker();
309 					return true;
310 				}},
311 				{key: "Escape", run: (view) => {
312 					if (!fontPickerState) return false;
313 					// Escape backs out of a highlighted font, like arrowing all the way up
314 					if (fontPickerState.selectedIndex !== -1) restoreTypedValue(view);
315 					closeFontPicker();
316 					return true;
317 				}},
318 				{key: "Mod-z", run: undo},
319 				{key: "Mod-y", run: redo},
320 				{key: "Mod-Shift-z", run: redo},
321 				{key: "Mod-o", run: () => { window.loadFile(); return true; }},
322 				{key: "Mod-s", run: () => { window.saveFile(); return true; }},
323 				{key: "F1", run: (view) => { toggleLineNumbers(view); return true; }},
324 				{key: "F2", run: (view) => { toggleLineWrapping(view); return true; }},
325 				indentWithTab,
326 				...searchKeymap.filter(binding => binding.key !== "Mod-f"),
327 				...defaultKeymap
328 			]),
329 			html(),
330 			EditorView.updateListener.of((update) => {
331 				if (update.docChanged) {
332 					const content = update.state.doc.toString();
333 					clearTimeout(window.updateTimer);
334 					window.updateTimer = setTimeout(() => updatePreviewCallback(content), 600);
335 					saveToStorageCallback(content);
336 
337 					refreshFontPicker(update.view);
338 				}
339 				// Close if the cursor moves off the font-family value
340 				if (fontPickerState && update.selectionSet && !applyingFont) {
341 					const range = getFontValueRange(update.state);
342 					const pos = update.state.selection.main.head;
343 					if (!range || pos < range.anchorPos || pos > range.to) closeFontPicker();
344 				}
345 			}),
346 			EditorView.inputHandler.of((view, from, to, text) => {
347 				if (text !== '>') return false;
348 				const before = view.state.doc.sliceString(Math.max(0, from - 20), from);
349 				if (before.endsWith('<!')) {
350 					const startPos = from - 2;
351 					view.dispatch({
352 						changes: { from: startPos, to: from, insert: ZINE_BOILERPLATE },
353 						selection: { anchor: getBoilerplateCursorPos(startPos) }
354 					});
355 					return true;
356 				}
357 				const match = before.match(/<(style|script)(\s[^>]*)?$/i);
358 				if (!match) return false;
359 				const tagName = match[1].toLowerCase();
360 				const closingTag = `</${tagName}>`;
361 				view.dispatch({
362 					changes: { from, to, insert: '>' + closingTag },
363 					selection: { anchor: from + 1 }
364 				});
365 				return true;
366 			}),
367 			githubDark,
368 			indentUnit.of("\t"),
369 			placeholder("Type <!> to insert zine boilerplate..."),
370 			EditorView.contentAttributes.of({
371 				'autocomplete': 'off',
372 				'autocorrect': 'off',
373 				'autocapitalize': 'off',
374 				'spellcheck': 'false'
375 			}),
376 			lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []),
377 			lineWrappingCompartment.of(initialLineWrappingExtension)
378 		]
379 	};
380 
381 	const editorView = new EditorView({
382 		state: EditorState.create(stateConfig),
383 		parent: document.getElementById('editor')
384 	});
385 
386 	if (isBoilerplate) {
387 		editorView.dispatch({
388 			selection: { anchor: getBoilerplateCursorPos() }
389 		});
390 	}
391 
392 	// Disable browser autocomplete on search panel inputs
393 	const editorElement = document.getElementById('editor');
394 	const searchInputObserver = new MutationObserver((mutations) => {
395 		for (const mutation of mutations) {
396 			for (const node of mutation.addedNodes) {
397 				if (node.nodeType === Node.ELEMENT_NODE) {
398 					const searchInputs = node.querySelectorAll?.('.cm-search input[name="search"], .cm-search input[name="replace"]');
399 					searchInputs?.forEach(input => input.setAttribute('autocomplete', 'off'));
400 				}
401 			}
402 		}
403 	});
404 	searchInputObserver.observe(editorElement, { childList: true, subtree: true });
405 
406 	return editorView;
407 }