/******************************************************************************* uBlock Origin Lite - a comprehensive, MV3-compliant content blocker Copyright (C) 2014-present Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uBlock */ import { dom, qs$, qsa$ } from './dom.js'; import { localRead, localWrite, sendMessage } from './ext.js'; import { faIconsInit } from './fa-icons.js'; import { i18n } from './i18n.js'; /******************************************************************************/ class Editor { constructor() { this.lastSavedText = ''; this.view = null; this.reYamlDocSeparator = /^(?:---|...)\s*$/; this.modifiedRange = { start: 0, end: 0 }; this.updateTimer = undefined; this.ioPanel = self.cm6.createViewPanel(); this.summaryPanel = self.cm6.createViewPanel(); this.panels = []; this.editors = {}; } async init() { await Promise.all([ import('./mode-editor.js').then(module => { this.editors['modes'] = new module.ModeEditor(this); }), import('./ro-dnr-editor.js').then(module => { this.editors['dnr.ro'] = new module.ReadOnlyDNREditor(this); }), import('./rw-dnr-editor.js').then(module => { this.editors['dnr.rw'] = new module.ReadWriteDNREditor(this); }), ]); const rulesetDetails = await sendMessage({ what: 'getRulesetDetails' }); const parent = qs$('#editors optgroup'); for ( const details of rulesetDetails ) { const option = document.createElement('option'); option.value = `dnr.ro.${details.id}`; option.textContent = details.name; parent.append(option); } this.validModes = Array.from(qsa$('#editors option')).map(a => a.value); const mode = await localRead('dashboard.develop.editor'); this.editorFromMode(mode); const text = this.normalizeEditorText(await this.editor.getText(this.mode)); const viewConfig = { text, yamlLike: true, oneDark: dom.cl.has(':root', 'dark'), updateListener: info => { this.viewUpdateListener(info); }, saveListener: ( ) => { this.saveEditorText(); }, lineError: true, spanError: true, // https://codemirror.net/examples/autocompletion/ autocompletion: { override: [ context => { return this.autoComplete(context); }, ], activateOnCompletion: ( ) => true, }, gutterClick: (view, info) => { return this.gutterClick(view, info); }, hoverTooltip: (view, pos, side) => { return this.hoverTooltip(view, pos, side); }, streamParser: this.streamParser, foldService: (state, from) => { return this.foldService(state, from); }, readOnly: this.isReadOnly(), }; viewConfig.panels = [ this.ioPanel, this.summaryPanel, ...this.panels ]; this.view = self.cm6.createEditorView(viewConfig, qs$('#cm-container')); this.lastSavedText = text; self.cm6.foldAll(this.view); self.cm6.resetUndoRedo(this.view); this.updateIOPanel(); this.editor.on?.(this); this.modifiedRange.start = 1; this.modifiedRange.end = this.view.state.doc.lines; this.updateViewAsync(); } normalizeEditorText(text) { text ||= ''; text = text.trim(); if ( text !== '' ) { text += '\n'; } return text; } setEditorText(text, saved = false) { text = this.normalizeEditorText(text); if ( saved ) { this.lastSavedText = text; } this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: text, }, }); this.view.focus(); } getEditorText() { return this.view.state.doc.toString(); } editorTextChanged() { const text = this.normalizeEditorText(this.getEditorText()); return text !== this.lastSavedText; } async selectEditor(mode) { if ( mode === this.mode ) { return; } this.editorFromMode(mode); const text = await this.editor.getText(this.mode); this.setEditorText(text); this.lastSavedText = this.getEditorText(); self.cm6.foldAll(this.view) self.cm6.resetUndoRedo(this.view); self.cm6.toggleReadOnly(this.view, this.isReadOnly()); this.updateIOPanel(); this.editor.on?.(this); this.modifiedRange.start = 1; this.modifiedRange.end = this.view.state.doc.lines; this.updateViewAsync(); } editorFromMode(mode) { if ( this.validModes.includes(mode) === false ) { mode = 'modes'; } if ( mode === this.mode ) { return mode; } let editor; if ( mode === 'modes' ) { editor = this.editors['modes']; } else if ( mode.startsWith('dnr.rw.') ) { editor = this.editors['dnr.rw']; } else if ( mode.startsWith('dnr.ro.') ) { editor = this.editors['dnr.ro']; } else { return; } this.editor?.off?.(this); this.editor = editor; this.mode = mode; const select = qs$('#editors'); select.value = mode; } isReadOnly() { return typeof this.editor.saveEditorText !== 'function'; } viewUpdateListener(info) { if ( info.docChanged === false ) { return; } for ( const transaction of info.transactions ) { if ( transaction.docChanged === false ) { continue; } this.addToModifiedRange(transaction); if ( transaction.isUserEvent('delete.backward') ) { this.smartBackspace(transaction); } else if ( transaction.isUserEvent('input.paste') ) { if ( this.editor.importFromPaste ) { this.editor.importFromPaste(this, transaction); } } else if ( transaction.isUserEvent('input') ) { if ( this.smartReturn(transaction) ) { continue; } this.smartSpacebar(transaction); } } this.updateViewAsync(); } updateViewAsync() { if ( this.updateTimer !== undefined ) { return; } this.updateTimer = self.setTimeout(( ) => { this.updateTimer = undefined; this.updateView(); }, 71); } updateView() { const { doc } = this.view.state; const changed = this.editorTextChanged(); dom.attr('#apply', 'disabled', changed ? null : ''); dom.attr('#revert', 'disabled', changed ? null : ''); if ( typeof this.editor.updateView !== 'function' ) { return; } let { start, end } = this.modifiedRange; if ( start === 0 || end === 0 ) { return; } this.modifiedRange.start = this.modifiedRange.end = 0; if ( start > doc.lines ) { start = doc.lines; } if ( end > doc.lines ) { end = doc.lines; } self.cm6.lineErrorClear(this.view, start, end); self.cm6.spanErrorClear(this.view, start, end); const firstLine = doc.line(start); const lastLine = doc.line(end); this.editor.updateView(this, firstLine, lastLine); } updateIOPanel() { const ioButtons = []; if ( this.editor.saveEditorText ) { ioButtons.push('apply', 'revert'); } if ( this.editor.importFromFile ) { ioButtons.push('import'); } if ( this.editor.exportToFile ) { ioButtons.push('export'); } if ( ioButtons.length === 0 ) { return this.ioPanel.render(this.view, null); } const template = document.querySelector('template.io-panel'); const fragment = template.content.cloneNode(true); const root = fragment.querySelector('.io-panel'); i18n.render(root); faIconsInit(root); root.dataset.io = ioButtons.join(' '); const config = { dom: root, mount: ( ) => { dom.on('#apply', 'click', ( ) => { this.saveEditorText(); }); dom.on('#revert', 'click', ( ) => { this.revertEditorText(); }); dom.on('#import', 'click', ( ) => { this.importFromFile() }); dom.on('#export', 'click', ( ) => { this.exportToFile(); }); } }; this.ioPanel.render(this.view, config); } updateSummaryPanel(dom) { if ( dom instanceof Object ) { if ( this.updateSummaryPanel.timer !== undefined ) { self.clearTimeout(this.updateSummaryPanel.timer); this.updateSummaryPanel.timer = undefined; } return this.summaryPanel.render(this.view, { dom }); } if ( this.updateSummaryPanel.timer !== undefined ) { return; } this.updateSummaryPanel.timer = self.setTimeout(( ) => { this.updateSummaryPanel.timer = undefined; this.summaryPanel.render(this.view, null); }, 157); } autoComplete(context) { if ( typeof this.editor.autoComplete !== 'function' ) { return null; } return this.editor.autoComplete(this, context); } hoverTooltip(view, pos, side) { if ( typeof this.editor.createTooltipWidget !== 'function' ) { return null; } const details = view.domAtPos(pos); const textNode = details.node; if ( textNode.nodeType !== 3 ) { return null; } const { parentElement } = textNode; const targetElement = parentElement.closest('[data-tooltip]'); if ( targetElement === null ) { return null; } const tooltipText = targetElement.getAttribute('data-tooltip'); if ( Boolean(tooltipText) === false ) { return null; } const start = pos - details.offset; const end = start + textNode.nodeValue.length; if ( start === pos && side < 0 || end === pos && side > 0 ) { return null; } return { above: true, pos: start, end, create: ( ) => { return { dom: this.editor.createTooltipWidget(tooltipText) }; }, }; } foldService(state, from) { if ( typeof this.editor.foldService !== 'function' ) { return null; } return this.editor.foldService(state, from); } // Details of YAML document(s) intersecting with a text span. If the text span // starts on a YAML document divider, the previous YAML document will be // included. If the text span ends on a YAML document divider, the next YAML // document will be included. snapToYamlDocument(doc, start, end) { let yamlDocStart = doc.lineAt(start).number; if ( this.reYamlDocSeparator.test(doc.line(yamlDocStart).text) ) { if ( yamlDocStart > 1 ) { yamlDocStart -= 1; } } while ( yamlDocStart > 1 ) { const line = doc.line(yamlDocStart); if ( this.reYamlDocSeparator.test(line.text) ) { break; } yamlDocStart -= 1; } const lastLine = doc.lines; let yamlDocEnd = doc.lineAt(end).number; if ( this.reYamlDocSeparator.test(doc.line(yamlDocEnd).text) ) { if ( yamlDocEnd < lastLine ) { yamlDocEnd += 1; } } while ( yamlDocEnd < lastLine ) { const line = doc.line(yamlDocEnd); if ( this.reYamlDocSeparator.test(line.text) ) { break; } yamlDocEnd += 1; } return { yamlDocStart, yamlDocEnd }; } rangeFromTransaction(transaction) { let from, to; transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => { if ( from === undefined || fromB < from ) { from = fromB; } if ( to === undefined || toB > to ) { to = toB; } }); return { from, to }; } addToModifiedRange(transaction) { const { from, to } = this.rangeFromTransaction(transaction); if ( from === undefined || to === undefined ) { return; } const { newDoc } = transaction; const { yamlDocStart, yamlDocEnd } = this.snapToYamlDocument(newDoc, from, to); if ( this.modifiedRange.start === 0 || yamlDocStart < this.modifiedRange.start ) { this.modifiedRange.start = yamlDocStart; } if ( this.modifiedRange.end === 0 || yamlDocEnd > this.modifiedRange.end ) { this.modifiedRange.end = yamlDocEnd; } } lineIndentAt(line) { const match = /^(?: {2})*/.exec(line.text); const indent = match !== null ? match[0].length : -1; if ( indent === -1 || (indent & 1) !== 0 ) { return -1; } return indent / 2; } getScopeAt(from, doc) { doc ||= this.view.state.doc; const lineFrom = doc.lineAt(from); const out = {}; let depth = this.lineIndentAt(lineFrom); if ( depth === -1 ) { return out; } const text = lineFrom.text.trim(); if ( text.startsWith('#') ) { return out; } const path = []; const end = text.indexOf(':'); if ( end !== -1 ) { const beg = text.startsWith('- ') ? 2 : 0; path.push(text.slice(beg, end+1)); } let lineNo = lineFrom.number; while ( depth > 0 && lineNo > 1 ) { lineNo -= 1; const lineBefore = doc.line(lineNo); const text = lineBefore.text.trim(); if ( text.startsWith('#') ) { continue; } if ( this.lineIndentAt(lineBefore) > (depth-1) ) { continue; } const match = /^- ([^:]+:)/.exec(text); if ( match !== null ) { path.unshift(match[1]); } else { path.unshift(text); } depth -= 1; } out.scope = path.join(''); out.depth = path.length; return out; } async saveEditorText() { if ( typeof this.editor.saveEditorText !== 'function' ) { return; } if ( this.editorTextChanged() === false ) { return; } const saved = await this.editor.saveEditorText(this); if ( saved !== true ) { return; } this.lastSavedText = this.normalizeEditorText(this.getEditorText()); this.updateView(); } revertEditorText() { if ( this.editorTextChanged() === false ) { return; } this.setEditorText(this.lastSavedText); } smartBackspace(transaction) { const { from, to } = this.rangeFromTransaction(transaction); if ( from === undefined || to === undefined ) { return; } if ( to !== from ) { return; } const { newDoc } = transaction; const line = newDoc.lineAt(from); if ( /^(?: {2})+-$/.test(line.text) === false ) { return; } this.view.dispatch({ changes: { from: from-3, to: from, insert: '' } }); return true; } lineIsArrayItem(doc, lineNo) { if ( lineNo < 1 || lineNo > doc.lines ) { return false; } const line = doc.line(lineNo); return /^(?: {2})+- /.test(line.text); } smartArrayItem(doc, from) { const line = doc.lineAt(from); if ( line.from === 0 ) { return; } const blanks = /^ *$/.exec(line.text); if ( blanks === null ) { return; } if ( this.editor.newlineAssistant ) { const { scope } = this.getScopeAt(line.from-1, doc); const insert = this.editor.newlineAssistant[scope]; if ( insert ) { this.view.dispatch({ changes: { from: line.from, to: line.to, insert }, selection: { anchor: line.from + insert.length }, }); return true; } } let targetIndent; if ( this.lineIsArrayItem(doc, line.number-1) ) { targetIndent = doc.line(line.number-1).text.indexOf('- '); } else if ( this.lineIsArrayItem(doc, line.number+1) ) { targetIndent = doc.line(line.number+1).text.indexOf('- '); } if ( targetIndent === undefined ) { return; } const indent = targetIndent - blanks[0].length; if ( indent < 0 || indent > 2 ) { return; } const insert = `${' '.repeat(indent)}- `; this.view.dispatch({ changes: { from, insert }, selection: { anchor: from + insert.length }, }); return true; } smartReturn(transaction) { const { from, to } = this.rangeFromTransaction(transaction); if ( from === undefined || to === undefined ) { return; } const { newDoc } = transaction; return this.smartArrayItem(newDoc, to); } smartSpacebar(transaction) { const { from, to } = this.rangeFromTransaction(transaction); if ( from === undefined || to === undefined ) { return; } if ( (to - from) !== 1 ) { return; } const { newDoc } = transaction; const line = newDoc.lineAt(to); const localTo = to - line.from; const before = line.text.slice(0, localTo); if ( /^(?: {1}| {3})$/.test(before) === false ) { return; } if ( this.smartArrayItem(newDoc, to) ) { return true; } this.view.dispatch({ changes: { from: to, insert: ' ' }, selection: { anchor: to + 1 }, }); return true; } gutterClick(view, info) { const reSeparator = /^(?:---|# ---)\s*/; const { doc } = view.state; const lineFirst = doc.lineAt(info.from); if ( lineFirst.text === '' ) { return false; } let { from, to } = lineFirst; if ( reSeparator.test(lineFirst.text) ) { let lineNo = lineFirst.number + 1; while ( lineNo < doc.lines ) { const line = doc.line(lineNo); if ( reSeparator.test(line.text) ) { break; } to = line.to; lineNo += 1; } } view.dispatch({ selection: { anchor: from, head: Math.min(to+1, doc.length) } }); view.focus(); return true; } importFromFile() { const editor = this.editor; if ( typeof editor.importFromFile !== 'function' ) { return; } const input = qs$('input[type="file"]'); input.accept = editor.ioAccept || ''; input.onchange = ev => { input.onchange = null; const file = ev.target.files[0]; if ( file === undefined || file.name === '' ) { return; } const fr = new FileReader(); fr.onload = ( ) => { if ( typeof fr.result !== 'string' ) { return; } editor.importFromFile(this, fr.result); }; fr.readAsText(file); }; // Reset to empty string, this will ensure a change event is properly // triggered if the user pick a file, even if it's the same as the last // one picked. input.value = ''; input.click(); } exportToFile() { const editor = this.editor; if ( typeof editor.exportToFile !== 'function' ) { return; } const text = this.getEditorText(); const result = editor.exportToFile(text); if ( result === undefined ) { return; } const { fname, data, mime } = result; const a = document.createElement('a'); a.href = `data:${mime};charset=utf-8,${encodeURIComponent(data)}`; dom.attr(a, 'download', fname || ''); dom.attr(a, 'type', mime); a.click(); } streamParser = { startState: ( ) => { return { scope: 0 }; }, token: (stream, state) => { if ( stream.sol() ) { if ( stream.match(/^---\s*$/) ) { return 'ubol-boundary'; } if ( stream.match(/^# ---\s*$/) ) { return 'ubol-boundary ubol-comment'; } if ( stream.match(/\.\.\.\s*$/) ) { return 'ubol-boundary'; } } const c = stream.peek(); if ( c === '#' ) { if ( (stream.pos === 0 || /\s/.test(stream.string.charAt(stream.pos - 1))) ) { stream.skipToEnd(); return 'ubol-comment'; } } if ( stream.eatSpace() ) { return null; } const { scope } = state; state.scope = 0; if ( scope === 0 && stream.match(/^[^:]+(?=:)/) ) { state.scope = 1; return 'ubol-keyword'; } if ( scope === 1 && stream.match(/^:(?: |$)/) ) { return 'ubol-punctuation'; } if ( stream.match(/^- /) ) { return 'ubol-punctuation'; } if ( this.editor.streamParserKeywords ) { if ( stream.match(this.editor.streamParserKeywords) ) { return 'ubol-literal'; } } if ( stream.match(/^\S+/) ) { return null; } stream.next(); return null; }, languageData: { commentTokens: { line: '#' }, }, tokenTable: [ 'ubol-boundary', 'ubol-keyword', 'ubol-comment', 'ubol-punctuation', 'ubol-literal', ], }; } /******************************************************************************/ async function start() { const editor = new Editor(); await editor.init(); dom.on('#editors', 'change', ( ) => { const select = qs$('#editors'); const mode = select.value; if ( mode === editor.mode ) { return; } editor.selectEditor(mode); localWrite('dashboard.develop.editor', editor.mode); }); } let observer = new IntersectionObserver(entries => { for ( const entry of entries ) { if ( entry.isIntersecting === false ) { continue; } start(); observer.disconnect(); observer = null; break; } }); observer.observe(qs$('section[data-pane="develop"]')); /******************************************************************************/