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 }