'use strict'; /** * Copyright 2009 Google Inc. * Copyright 2020 John McLear - The Etherpad Foundation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ let documentAttributeManager; const browser = require('./browser'); const padutils = require('./pad_utils').padutils; const Ace2Common = require('./ace2_common'); const $ = require('./rjquery').$; const isNodeText = Ace2Common.isNodeText; const getAssoc = Ace2Common.getAssoc; const setAssoc = Ace2Common.setAssoc; const htmlPrettyEscape = Ace2Common.htmlPrettyEscape; const noop = Ace2Common.noop; const hooks = require('./pluginfw/hooks'); function Ace2Inner() { const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const colorutils = require('./colorutils').colorutils; const makeContentCollector = require('./contentcollector').makeContentCollector; const makeCSSManager = require('./cssmanager').makeCSSManager; const domline = require('./domline').domline; const AttribPool = require('./AttributePool'); const Changeset = require('./Changeset'); const ChangesetUtils = require('./ChangesetUtils'); const linestylefilter = require('./linestylefilter').linestylefilter; const SkipList = require('./skiplist'); const undoModule = require('./undomodule').undoModule; const AttributeManager = require('./AttributeManager'); const Scroll = require('./scroll'); const DEBUG = false; const THE_TAB = ' '; // 4 const MAX_LIST_LEVEL = 16; const FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough']; const SELECT_BUTTON_CLASS = 'selected'; const caughtErrors = []; let thisAuthor = ''; let disposed = false; const editorInfo = parent.editorInfo; const focus = () => { window.focus(); }; const iframe = window.frameElement; const outerWin = iframe.ace_outerWin; iframe.ace_outerWin = null; // prevent IE 6 memory leak const sideDiv = iframe.nextSibling; const lineMetricsDiv = sideDiv.nextSibling; let lineNumbersShown; let sideDivInner; const initLineNumbers = () => { const htmlOpen = '
1'; const htmlClose = '
'; lineNumbersShown = 1; sideDiv.innerHTML = `${htmlOpen}${htmlClose}`; sideDivInner = outerWin.document.getElementById('sidedivinner'); $(sideDiv).addClass('sidediv'); }; initLineNumbers(); const scroll = Scroll.init(outerWin); let outsideKeyDown = noop; let outsideKeyPress = (e) => true; let outsideNotifyDirty = noop; // selFocusAtStart -- determines whether the selection extends "backwards", so that the focus // point (controlled with the arrow keys) is at the beginning; not supported in IE, though // native IE selections have that behavior (which we try not to interfere with). // Must be false if selection is collapsed! const rep = { lines: new SkipList(), selStart: null, selEnd: null, selFocusAtStart: false, alltext: '', alines: [], apool: new AttribPool(), }; // lines, alltext, alines, and DOM are set up in init() if (undoModule.enabled) { undoModule.apool = rep.apool; } let root, doc; // set in init() let isEditable = true; let doesWrap = true; let hasLineNumbers = true; let isStyled = true; let console = (DEBUG && window.console); if (!window.console) { const names = [ 'log', 'debug', 'info', 'warn', 'error', 'assert', 'dir', 'dirxml', 'group', 'groupEnd', 'time', 'timeEnd', 'count', 'trace', 'profile', 'profileEnd', ]; console = {}; for (let i = 0; i < names.length; ++i) console[names[i]] = noop; } let PROFILER = window.PROFILER; if (!PROFILER) { PROFILER = () => ({ start: noop, mark: noop, literal: noop, end: noop, cancel: noop, }); } // "dmesg" is for displaying messages in the in-page output pane // visible when "?djs=1" is appended to the pad URL. It generally // remains a no-op unless djs is enabled, but we make a habit of // only calling it in error cases or while debugging. let dmesg = noop; window.dmesg = noop; const scheduler = parent; // hack for opera required let dynamicCSS = null; let outerDynamicCSS = null; let parentDynamicCSS = null; const performDocumentReplaceRange = (start, end, newText) => { if (start === undefined) start = rep.selStart; if (end === undefined) end = rep.selEnd; // dmesg(String([start.toSource(),end.toSource(),newText.toSource()])); // start[0]: <--- start[1] --->CCCCCCCCCCC\n // CCCCCCCCCCCCCCCCCCCC\n // CCCC\n // end[0]: -------\n const builder = Changeset.builder(rep.lines.totalWidth()); ChangesetUtils.buildKeepToStartOfRange(rep, builder, start); ChangesetUtils.buildRemoveRange(rep, builder, start, end); builder.insert(newText, [ ['author', thisAuthor], ], rep.apool); const cs = builder.toString(); performDocumentApplyChangeset(cs); }; const initDynamicCSS = () => { dynamicCSS = makeCSSManager('dynamicsyntax'); outerDynamicCSS = makeCSSManager('dynamicsyntax', 'outer'); parentDynamicCSS = makeCSSManager('dynamicsyntax', 'parent'); }; const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { withCallbacks: (operationName, f) => { inCallStackIfNecessary(operationName, () => { fastIncorp(1); f( { setDocumentAttributedText: (atext) => { setDocAText(atext); }, applyChangesetToDocument: (changeset, preferInsertionAfterCaret) => { const oldEventType = currentCallStack.editEvent.eventType; currentCallStack.startNewEvent('nonundoable'); performDocumentApplyChangeset(changeset, preferInsertionAfterCaret); currentCallStack.startNewEvent(oldEventType); }, }); }); }, }); const authorInfos = {}; // presence of key determines if author is present in doc const getAuthorInfos = () => authorInfos; editorInfo.ace_getAuthorInfos = getAuthorInfos; const setAuthorStyle = (author, info) => { if (!dynamicCSS) { return; } const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author)); const authorStyleSet = hooks.callAll('aceSetAuthorStyle', { dynamicCSS, parentDynamicCSS, outerDynamicCSS, info, author, authorSelector, }); // Prevent default behaviour if any hook says so if (authorStyleSet.some((it) => it)) { return; } if (!info) { dynamicCSS.removeSelectorStyle(authorSelector); parentDynamicCSS.removeSelectorStyle(authorSelector); } else if (info.bgcolor) { let bgcolor = info.bgcolor; if ((typeof info.fade) === 'number') { bgcolor = fadeColor(bgcolor, info.fade); } const authorStyle = dynamicCSS.selectorStyle(authorSelector); const parentAuthorStyle = parentDynamicCSS.selectorStyle(authorSelector); // author color authorStyle.backgroundColor = bgcolor; parentAuthorStyle.backgroundColor = bgcolor; const textColor = colorutils.textColorFromBackgroundColor(bgcolor, parent.parent.clientVars.skinName); authorStyle.color = textColor; parentAuthorStyle.color = textColor; } }; const setAuthorInfo = (author, info) => { if ((typeof author) !== 'string') { // Potentially caused by: https://github.com/ether/etherpad-lite/issues/2802"); throw new Error(`setAuthorInfo: author (${author}) is not a string`); } if (!info) { delete authorInfos[author]; } else { authorInfos[author] = info; } setAuthorStyle(author, info); }; const getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => { if (c === '.') return '-'; return `z${c.charCodeAt(0)}z`; })}`; const className2Author = (className) => { if (className.substring(0, 7) === 'author-') { return className.substring(7).replace(/[a-y0-9]+|-|z.+?z/g, (cc) => { if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') { return String.fromCharCode(Number(cc.slice(1, -1))); } else { return cc; } }); } return null; }; const getAuthorColorClassSelector = (oneClassName) => `.authorColors .${oneClassName}`; const fadeColor = (colorCSS, fadeFrac) => { let color = colorutils.css2triple(colorCSS); color = colorutils.blend(color, [1, 1, 1], fadeFrac); return colorutils.triple2css(color); }; editorInfo.ace_getRep = () => rep; editorInfo.ace_getAuthor = () => thisAuthor; const _nonScrollableEditEvents = { applyChangesToBase: 1, }; hooks.callAll('aceRegisterNonScrollableEditEvents').forEach((eventType) => { _nonScrollableEditEvents[eventType] = 1; }); const isScrollableEditEvent = (eventType) => !_nonScrollableEditEvents[eventType]; let currentCallStack = null; const inCallStack = (type, action) => { if (disposed) return; if (currentCallStack) { // Do not uncomment this in production. It will break Etherpad being provided in iFrames. // I am leaving this in for testing usefulness. // top.console.error(`Can't enter callstack ${type}, already in ${currentCallStack.type}`); } const newEditEvent = (eventType) => ({ eventType, backset: null, }); const submitOldEvent = (evt) => { if (rep.selStart && rep.selEnd) { const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; evt.selStart = selStartChar; evt.selEnd = selEndChar; evt.selFocusAtStart = rep.selFocusAtStart; } if (undoModule.enabled) { let undoWorked = false; try { if (isPadLoading(evt.eventType)) { undoModule.clearHistory(); } else if (evt.eventType === 'nonundoable') { if (evt.changeset) { undoModule.reportExternalChange(evt.changeset); } } else { undoModule.reportEvent(evt); } undoWorked = true; } finally { if (!undoWorked) { undoModule.enabled = false; // for safety } } } }; const startNewEvent = (eventType, dontSubmitOld) => { const oldEvent = currentCallStack.editEvent; if (!dontSubmitOld) { submitOldEvent(oldEvent); } currentCallStack.editEvent = newEditEvent(eventType); return oldEvent; }; currentCallStack = { type, docTextChanged: false, selectionAffected: false, userChangedSelection: false, domClean: false, isUserChange: false, // is this a "user change" type of call-stack repChanged: false, editEvent: newEditEvent(type), startNewEvent, }; let cleanExit = false; let result; try { result = action(); hooks.callAll('aceEditEvent', { callstack: currentCallStack, editorInfo, rep, documentAttributeManager, }); cleanExit = true; } catch (e) { caughtErrors.push( { error: e, time: +new Date(), }); dmesg(e.toString()); throw e; } finally { const cs = currentCallStack; if (cleanExit) { submitOldEvent(cs.editEvent); if (cs.domClean && cs.type !== 'setup') { // if (cs.isUserChange) // { // if (cs.repChanged) parenModule.notifyChange(); // else parenModule.notifyTick(); // } if (cs.selectionAffected) { updateBrowserSelectionFromRep(); } if ((cs.docTextChanged || cs.userChangedSelection) && isScrollableEditEvent(cs.type)) { scrollSelectionIntoView(); } if (cs.docTextChanged && cs.type.indexOf('importText') < 0) { outsideNotifyDirty(); } } } else if (currentCallStack.type === 'idleWorkTimer') { idleWorkTimer.atLeast(1000); } currentCallStack = null; } return result; }; editorInfo.ace_inCallStack = inCallStack; const inCallStackIfNecessary = (type, action) => { if (!currentCallStack) { inCallStack(type, action); } else { action(); } }; editorInfo.ace_inCallStackIfNecessary = inCallStackIfNecessary; const dispose = () => { disposed = true; if (idleWorkTimer) idleWorkTimer.never(); teardown(); }; const setWraps = (newVal) => { doesWrap = newVal; root.classList.toggle('doesWrap', doesWrap); scheduler.setTimeout(() => { inCallStackIfNecessary('setWraps', () => { fastIncorp(7); recreateDOM(); fixView(); }); }, 0); }; const setStyled = (newVal) => { const oldVal = isStyled; isStyled = !!newVal; if (newVal !== oldVal) { if (!newVal) { // clear styles inCallStackIfNecessary('setStyled', () => { fastIncorp(12); const clearStyles = []; for (const k of Object.keys(STYLE_ATTRIBS)) { clearStyles.push([k, '']); } performDocumentApplyAttributesToCharRange(0, rep.alltext.length, clearStyles); }); } } }; const setTextFace = (face) => { root.style.fontFamily = face; lineMetricsDiv.style.fontFamily = face; }; const recreateDOM = () => { // precond: normalized recolorLinesInRange(0, rep.alltext.length); }; const setEditable = (newVal) => { isEditable = newVal; root.contentEditable = isEditable ? 'true' : 'false'; root.classList.toggle('static', !isEditable); }; const enforceEditability = () => setEditable(isEditable); const importText = (text, undoable, dontProcess) => { let lines; if (dontProcess) { if (text.charAt(text.length - 1) !== '\n') { throw new Error('new raw text must end with newline'); } if (/[\r\t\xa0]/.exec(text)) { throw new Error('new raw text must not contain CR, tab, or nbsp'); } lines = text.substring(0, text.length - 1).split('\n'); } else { lines = text.split('\n').map(textify); } let newText = '\n'; if (lines.length > 0) { newText = `${lines.join('\n')}\n`; } inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { setDocText(newText); }); if (dontProcess && rep.alltext !== text) { throw new Error('mismatch error setting raw text in importText'); } }; const importAText = (atext, apoolJsonObj, undoable) => { atext = Changeset.cloneAText(atext); if (apoolJsonObj) { const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj); atext.attribs = Changeset.moveOpsToNewPool(atext.attribs, wireApool, rep.apool); } inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { setDocAText(atext); }); }; const setDocAText = (atext) => { if (atext.text === '') { /* * The server is fine with atext.text being an empty string, but the front * end is not, and crashes. * * It is not clear if this is a problem in the server or in the client * code, and this is a client-side hack fix. The underlying problem needs * to be investigated. * * See for reference: * - https://github.com/ether/etherpad-lite/issues/3861 */ atext.text = '\n'; } fastIncorp(8); const oldLen = rep.lines.totalWidth(); const numLines = rep.lines.length(); const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; const assem = Changeset.smartOpAssembler(); const o = Changeset.newOp('-'); o.chars = upToLastLine; o.lines = numLines - 1; assem.append(o); o.chars = lastLineLength; o.lines = 0; assem.append(o); Changeset.appendATextToAssembler(atext, assem); const newLen = oldLen + assem.getLengthChange(); const changeset = Changeset.checkRep( Changeset.pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1))); performDocumentApplyChangeset(changeset); performSelectionChange( [0, rep.lines.atIndex(0).lineMarker], [0, rep.lines.atIndex(0).lineMarker] ); idleWorkTimer.atMost(100); if (rep.alltext !== atext.text) { dmesg(htmlPrettyEscape(rep.alltext)); dmesg(htmlPrettyEscape(atext.text)); throw new Error('mismatch error setting raw text in setDocAText'); } }; const setDocText = (text) => { setDocAText(Changeset.makeAText(text)); }; const getDocText = () => { const alltext = rep.alltext; let len = alltext.length; if (len > 0) len--; // final extra newline return alltext.substring(0, len); }; const exportText = () => { if (currentCallStack && !currentCallStack.domClean) { inCallStackIfNecessary('exportText', () => { fastIncorp(2); }); } return getDocText(); }; const editorChangedSize = () => fixView(); const setOnKeyPress = (handler) => { outsideKeyPress = handler; }; const setOnKeyDown = (handler) => { outsideKeyDown = handler; }; const setNotifyDirty = (handler) => { outsideNotifyDirty = handler; }; const getFormattedCode = () => { if (currentCallStack && !currentCallStack.domClean) { inCallStackIfNecessary('getFormattedCode', incorporateUserChanges); } const buf = []; if (rep.lines.length() > 0) { // should be the case, even for empty file let entry = rep.lines.atIndex(0); while (entry) { const domInfo = entry.domInfo; buf.push((domInfo && domInfo.getInnerHTML()) || domline.processSpaces(domline.escapeHTML(entry.text), doesWrap) || ' ' /* empty line*/); entry = rep.lines.next(entry); } } return `
${buf.join('
\n
')}
`; }; const CMDS = { clearauthorship: (prompt) => { if ((!(rep.selStart && rep.selEnd)) || isCaret()) { if (prompt) { prompt(); } else { performDocumentApplyAttributesToCharRange(0, rep.alltext.length, [ ['author', ''], ]); } } else { setAttributeOnSelection('author', ''); } }, }; const execCommand = (cmd, ...args) => { cmd = cmd.toLowerCase(); if (CMDS[cmd]) { inCallStackIfNecessary(cmd, () => { fastIncorp(9); CMDS[cmd](...args); }); } }; const replaceRange = (start, end, text) => { inCallStackIfNecessary('replaceRange', () => { fastIncorp(9); performDocumentReplaceRange(start, end, text); }); }; editorInfo.ace_callWithAce = (fn, callStack, normalize) => { let wrapper = () => fn(editorInfo); if (normalize !== undefined) { const wrapper1 = wrapper; wrapper = () => { editorInfo.ace_fastIncorp(9); wrapper1(); }; } if (callStack !== undefined) { return editorInfo.ace_inCallStack(callStack, wrapper); } else { return wrapper(); } }; // This methed exposes a setter for some ace properties // @param key the name of the parameter // @param value the value to set to editorInfo.ace_setProperty = (key, value) => { // These properties are exposed const setters = { wraps: setWraps, showsauthorcolors: (val) => root.classList.toggle('authorColors', !!val), showsuserselections: (val) => root.classList.toggle('userSelections', !!val), showslinenumbers: (value) => { hasLineNumbers = !!value; sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers); fixView(); }, grayedout: (val) => outerWin.document.body.classList.toggle('grayedout', !!val), dmesg: () => { dmesg = window.dmesg = value; }, userauthor: (value) => { thisAuthor = String(value); documentAttributeManager.author = thisAuthor; }, styled: setStyled, textface: setTextFace, rtlistrue: (value) => { root.classList.toggle('rtl', value); root.classList.toggle('ltr', !value); document.documentElement.dir = value ? 'rtl' : 'ltr'; }, }; const setter = setters[key.toLowerCase()]; // check if setter is present if (setter !== undefined) { setter(value); } }; editorInfo.ace_setBaseText = (txt) => { changesetTracker.setBaseText(txt); }; editorInfo.ace_setBaseAttributedText = (atxt, apoolJsonObj) => { changesetTracker.setBaseAttributedText(atxt, apoolJsonObj); }; editorInfo.ace_applyChangesToBase = (c, optAuthor, apoolJsonObj) => { changesetTracker.applyChangesToBase(c, optAuthor, apoolJsonObj); }; editorInfo.ace_prepareUserChangeset = () => changesetTracker.prepareUserChangeset(); editorInfo.ace_applyPreparedChangesetToBase = () => { changesetTracker.applyPreparedChangesetToBase(); }; editorInfo.ace_setUserChangeNotificationCallback = (f) => { changesetTracker.setUserChangeNotificationCallback(f); }; editorInfo.ace_setAuthorInfo = (author, info) => { setAuthorInfo(author, info); }; editorInfo.ace_setAuthorSelectionRange = (author, start, end) => { changesetTracker.setAuthorSelectionRange(author, start, end); }; editorInfo.ace_getUnhandledErrors = () => caughtErrors.slice(); editorInfo.ace_getDocument = () => doc; editorInfo.ace_getDebugProperty = (prop) => { if (prop === 'debugger') { // obfuscate "eval" so as not to scare yuicompressor window['ev' + 'al']('debugger'); } else if (prop === 'rep') { return rep; } else if (prop === 'window') { return window; } else if (prop === 'document') { return document; } return undefined; }; const now = () => Date.now(); const newTimeLimit = (ms) => { const startTime = now(); let exceededAlready = false; let printedTrace = false; const isTimeUp = () => { if (exceededAlready) { if ((!printedTrace)) { // && now() - startTime - ms > 300) { printedTrace = true; } return true; } const elapsed = now() - startTime; if (elapsed > ms) { exceededAlready = true; return true; } else { return false; } }; isTimeUp.elapsed = () => now() - startTime; return isTimeUp; }; const makeIdleAction = (func) => { let scheduledTimeout = null; let scheduledTime = 0; const unschedule = () => { if (scheduledTimeout) { scheduler.clearTimeout(scheduledTimeout); scheduledTimeout = null; } }; const reschedule = (time) => { unschedule(); scheduledTime = time; let delay = time - now(); if (delay < 0) delay = 0; scheduledTimeout = scheduler.setTimeout(callback, delay); }; const callback = () => { scheduledTimeout = null; // func may reschedule the action func(); }; return { atMost: (ms) => { const latestTime = now() + ms; if ((!scheduledTimeout) || scheduledTime > latestTime) { reschedule(latestTime); } }, // atLeast(ms) will schedule the action if not scheduled yet. // In other words, "infinity" is replaced by ms, even though // it is technically larger. atLeast: (ms) => { const earliestTime = now() + ms; if ((!scheduledTimeout) || scheduledTime < earliestTime) { reschedule(earliestTime); } }, never: () => { unschedule(); }, }; }; const fastIncorp = (n) => { // normalize but don't do any lexing or anything incorporateUserChanges(); }; editorInfo.ace_fastIncorp = fastIncorp; const idleWorkTimer = makeIdleAction(() => { if (inInternationalComposition) { // don't do idle input incorporation during international input composition idleWorkTimer.atLeast(500); return; } inCallStackIfNecessary('idleWorkTimer', () => { const isTimeUp = newTimeLimit(250); let finishedImportantWork = false; let finishedWork = false; try { incorporateUserChanges(); if (isTimeUp()) return; updateLineNumbers(); // update line numbers if any time left if (isTimeUp()) return; finishedImportantWork = true; finishedWork = true; } finally { if (finishedWork) { idleWorkTimer.atMost(1000); } else if (finishedImportantWork) { // if we've finished highlighting the view area, // more highlighting could be counter-productive, // e.g. if the user just opened a triple-quote and will soon close it. idleWorkTimer.atMost(500); } else { let timeToWait = Math.round(isTimeUp.elapsed() / 2); if (timeToWait < 100) timeToWait = 100; idleWorkTimer.atMost(timeToWait); } } }); }); let _nextId = 1; const uniqueId = (n) => { // not actually guaranteed to be unique, e.g. if user copy-pastes // nodes with ids const nid = n.id; if (nid) return nid; return (n.id = `magicdomid${_nextId++}`); }; const recolorLinesInRange = (startChar, endChar) => { if (endChar <= startChar) return; if (startChar < 0 || startChar >= rep.lines.totalWidth()) return; let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary let lineStart = rep.lines.offsetOfEntry(lineEntry); let lineIndex = rep.lines.indexOfEntry(lineEntry); let selectionNeedsResetting = false; let firstLine = null; // tokenFunc function; accesses current value of lineEntry and curDocChar, // also mutates curDocChar const tokenFunc = (tokenText, tokenClass) => { lineEntry.domInfo.appendSpan(tokenText, tokenClass); }; while (lineEntry && lineStart < endChar) { const lineEnd = lineStart + lineEntry.width; lineEntry.domInfo.clearSpans(); getSpansForLine(lineEntry, tokenFunc, lineStart); lineEntry.domInfo.finishUpdate(); markNodeClean(lineEntry.lineNode); if (rep.selStart && rep.selStart[0] === lineIndex || rep.selEnd && rep.selEnd[0] === lineIndex) { selectionNeedsResetting = true; } if (firstLine == null) firstLine = lineIndex; lineStart = lineEnd; lineEntry = rep.lines.next(lineEntry); lineIndex++; } if (selectionNeedsResetting) { currentCallStack.selectionAffected = true; } }; // like getSpansForRange, but for a line, and the func takes (text,class) // instead of (width,class); excludes the trailing '\n' from // consideration by func const getSpansForLine = (lineEntry, textAndClassFunc, lineEntryOffsetHint) => { let lineEntryOffset = lineEntryOffsetHint; if ((typeof lineEntryOffset) !== 'number') { lineEntryOffset = rep.lines.offsetOfEntry(lineEntry); } const text = lineEntry.text; if (text.length === 0) { // allow getLineStyleFilter to set line-div styles const func = linestylefilter.getLineStyleFilter( 0, '', textAndClassFunc, rep.apool); func('', ''); } else { let filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser); const lineNum = rep.lines.indexOfEntry(lineEntry); const aline = rep.alines[lineNum]; filteredFunc = linestylefilter.getLineStyleFilter( text.length, aline, filteredFunc, rep.apool); filteredFunc(text, ''); } }; let observedChanges; const clearObservedChanges = () => { observedChanges = { cleanNodesNearChanges: {}, }; }; clearObservedChanges(); const getCleanNodeByKey = (key) => { const p = PROFILER('getCleanNodeByKey', false); // eslint-disable-line new-cap p.extra = 0; let n = doc.getElementById(key); // copying and pasting can lead to duplicate ids while (n && isNodeDirty(n)) { p.extra++; n.id = ''; n = doc.getElementById(key); } p.literal(p.extra, 'extra'); p.end(); return n; }; const observeChangesAroundNode = (node) => { // Around this top-level DOM node, look for changes to the document // (from how it looks in our representation) and record them in a way // that can be used to "normalize" the document (apply the changes to our // representation, and put the DOM in a canonical form). let cleanNode; let hasAdjacentDirtyness; if (!isNodeDirty(node)) { cleanNode = node; const prevSib = cleanNode.previousSibling; const nextSib = cleanNode.nextSibling; hasAdjacentDirtyness = ((prevSib && isNodeDirty(prevSib)) || (nextSib && isNodeDirty(nextSib))); } else { // node is dirty, look for clean node above let upNode = node.previousSibling; while (upNode && isNodeDirty(upNode)) { upNode = upNode.previousSibling; } if (upNode) { cleanNode = upNode; } else { let downNode = node.nextSibling; while (downNode && isNodeDirty(downNode)) { downNode = downNode.nextSibling; } if (downNode) { cleanNode = downNode; } } if (!cleanNode) { // Couldn't find any adjacent clean nodes! // Since top and bottom of doc is dirty, the dirty area will be detected. return; } hasAdjacentDirtyness = true; } if (hasAdjacentDirtyness) { // previous or next line is dirty observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; } else { // next and prev lines are clean (if they exist) const lineKey = uniqueId(cleanNode); const prevSib = cleanNode.previousSibling; const nextSib = cleanNode.nextSibling; const actualPrevKey = ((prevSib && uniqueId(prevSib)) || null); const actualNextKey = ((nextSib && uniqueId(nextSib)) || null); const repPrevEntry = rep.lines.prev(rep.lines.atKey(lineKey)); const repNextEntry = rep.lines.next(rep.lines.atKey(lineKey)); const repPrevKey = ((repPrevEntry && repPrevEntry.key) || null); const repNextKey = ((repNextEntry && repNextEntry.key) || null); if (actualPrevKey !== repPrevKey || actualNextKey !== repNextKey) { observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; } } }; const observeChangesAroundSelection = () => { if (currentCallStack.observedSelection) return; currentCallStack.observedSelection = true; const p = PROFILER('getSelection', false); // eslint-disable-line new-cap const selection = getSelection(); p.end(); if (selection) { const node1 = topLevel(selection.startPoint.node); const node2 = topLevel(selection.endPoint.node); if (node1) observeChangesAroundNode(node1); if (node2 && node1 !== node2) { observeChangesAroundNode(node2); } } }; const observeSuspiciousNodes = () => { // inspired by Firefox bug #473255, where pasting formatted text // causes the cursor to jump away, making the new HTML never found. if (root.getElementsByTagName) { const nds = root.getElementsByTagName('style'); for (let i = 0; i < nds.length; i++) { const n = topLevel(nds[i]); if (n && n.parentNode === root) { observeChangesAroundNode(n); } } } }; const incorporateUserChanges = () => { if (currentCallStack.domClean) return false; currentCallStack.isUserChange = true; if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false; const p = PROFILER('incorp', false); // eslint-disable-line new-cap // returns true if dom changes were made if (!root.firstChild) { root.innerHTML = '
'; } p.mark('obs'); observeChangesAroundSelection(); observeSuspiciousNodes(); p.mark('dirty'); let dirtyRanges = getDirtyRanges(); let dirtyRangesCheckOut = true; let j = 0; let a, b; let scrollToTheLeftNeeded = false; while (j < dirtyRanges.length) { a = dirtyRanges[j][0]; b = dirtyRanges[j][1]; if (!((a === 0 || getCleanNodeByKey(rep.lines.atIndex(a - 1).key)) && (b === rep.lines.length() || getCleanNodeByKey(rep.lines.atIndex(b).key)))) { dirtyRangesCheckOut = false; break; } j++; } if (!dirtyRangesCheckOut) { const numBodyNodes = root.childNodes.length; for (let k = 0; k < numBodyNodes; k++) { const bodyNode = root.childNodes.item(k); if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) { observeChangesAroundNode(bodyNode); } } dirtyRanges = getDirtyRanges(); } clearObservedChanges(); p.mark('getsel'); const selection = getSelection(); let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection let i = 0; const splicesToDo = []; let netNumLinesChangeSoFar = 0; const toDeleteAtEnd = []; p.mark('ranges'); p.literal(dirtyRanges.length, 'numdirt'); const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]] while (i < dirtyRanges.length) { const range = dirtyRanges[i]; a = range[0]; b = range[1]; let firstDirtyNode = (((a === 0) && root.firstChild) || getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling); firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode); let lastDirtyNode = (((b === rep.lines.length()) && root.lastChild) || getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling); lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode); if (firstDirtyNode && lastDirtyNode) { const cc = makeContentCollector(isStyled, browser, rep.apool, className2Author); cc.notifySelection(selection); const dirtyNodes = []; for (let n = firstDirtyNode; n && !(n.previousSibling && n.previousSibling === lastDirtyNode); n = n.nextSibling) { cc.collectContent(n); dirtyNodes.push(n); } cc.notifyNextNode(lastDirtyNode.nextSibling); let lines = cc.getLines(); if ((lines.length <= 1 || lines[lines.length - 1] !== '') && lastDirtyNode.nextSibling) { // dirty region doesn't currently end a line, even taking the following node // (or lack of node) into account, so include the following clean node. // It could be SPAN or a DIV; basically this is any case where the contentCollector // decides it isn't done. // Note that this clean node might need to be there for the next dirty range. b++; const cleanLine = lastDirtyNode.nextSibling; cc.collectContent(cleanLine); toDeleteAtEnd.push(cleanLine); cc.notifyNextNode(cleanLine.nextSibling); } const ccData = cc.finish(); const ss = ccData.selStart; const se = ccData.selEnd; lines = ccData.lines; const lineAttribs = ccData.lineAttribs; const linesWrapped = ccData.linesWrapped; if (linesWrapped > 0) { // Chrome decides in its infinite wisdom that it's okay to put the browser's visisble // window in the middle of the span. An outcome of this is that the first chars of the // string are no longer visible to the user.. Yay chrome.. Move the browser's visible area // to the left hand side of the span. Firefox isn't quite so bad, but it's still pretty // quirky. scrollToTheLeftNeeded = true; } if (ss[0] >= 0) selStart = [ss[0] + a + netNumLinesChangeSoFar, ss[1]]; if (se[0] >= 0) selEnd = [se[0] + a + netNumLinesChangeSoFar, se[1]]; const entries = []; const nodeToAddAfter = lastDirtyNode; const lineNodeInfos = new Array(lines.length); for (let k = 0; k < lines.length; k++) { const lineString = lines[k]; const newEntry = createDomLineEntry(lineString); entries.push(newEntry); lineNodeInfos[k] = newEntry.domInfo; } // var fragment = magicdom.wrapDom(document.createDocumentFragment()); domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]); dirtyNodes.forEach((n) => { toDeleteAtEnd.push(n); }); const spliceHints = {}; if (selStart) spliceHints.selStart = selStart; if (selEnd) spliceHints.selEnd = selEnd; splicesToDo.push([a + netNumLinesChangeSoFar, b - a, entries, lineAttribs, spliceHints]); netNumLinesChangeSoFar += (lines.length - (b - a)); } else if (b > a) { splicesToDo.push([a + netNumLinesChangeSoFar, b - a, [], []]); } i++; } const domChanges = (splicesToDo.length > 0); // update the representation p.mark('splice'); splicesToDo.forEach((splice) => { doIncorpLineSplice(splice[0], splice[1], splice[2], splice[3], splice[4]); }); // do DOM inserts p.mark('insert'); domInsertsNeeded.forEach((ins) => { insertDomLines(ins[0], ins[1]); }); p.mark('del'); // delete old dom nodes toDeleteAtEnd.forEach((n) => { // var id = n.uniqueId(); // parent of n may not be "root" in IE due to non-tree-shaped DOM (wtf) if (n.parentNode) n.parentNode.removeChild(n); // dmesg(htmlPrettyEscape(htmlForRemovedChild(n))); }); // needed to stop chrome from breaking the ui when long strings without spaces are pasted if (scrollToTheLeftNeeded) { $('#innerdocbody').scrollLeft(0); } p.mark('findsel'); // if the nodes that define the selection weren't encountered during // content collection, figure out where those nodes are now. if (selection && !selStart) { // if (domChanges) dmesg("selection not collected"); const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', { callstack: currentCallStack, editorInfo, rep, root, point: selection.startPoint, documentAttributeManager, }); selStart = (selStartFromHook == null || selStartFromHook.length === 0) ? getLineAndCharForPoint(selection.startPoint) : selStartFromHook; } if (selection && !selEnd) { const selEndFromHook = hooks.callAll('aceEndLineAndCharForPoint', { callstack: currentCallStack, editorInfo, rep, root, point: selection.endPoint, documentAttributeManager, }); selEnd = (selEndFromHook == null || selEndFromHook.length === 0) ? getLineAndCharForPoint(selection.endPoint) : selEndFromHook; } // selection from content collection can, in various ways, extend past final // BR in firefox DOM, so cap the line const numLines = rep.lines.length(); if (selStart && selStart[0] >= numLines) { selStart[0] = numLines - 1; selStart[1] = rep.lines.atIndex(selStart[0]).text.length; } if (selEnd && selEnd[0] >= numLines) { selEnd[0] = numLines - 1; selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length; } p.mark('repsel'); // update rep if we have a new selection // NOTE: IE loses the selection when you click stuff in e.g. the // editbar, so removing the selection when it's lost is not a good // idea. if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart); // update browser selection p.mark('browsel'); if (selection && (domChanges || isCaret())) { // if no DOM changes (not this case), want to treat range selection delicately, // e.g. in IE not lose which end of the selection is the focus/anchor; // on the other hand, we may have just noticed a press of PageUp/PageDown currentCallStack.selectionAffected = true; } currentCallStack.domClean = true; p.mark('fixview'); fixView(); p.end('END'); return domChanges; }; const STYLE_ATTRIBS = { bold: true, italic: true, underline: true, strikethrough: true, list: true, }; const isStyleAttribute = (aname) => !!STYLE_ATTRIBS[aname]; const isDefaultLineAttribute = (aname) => AttributeManager.DEFAULT_LINE_ATTRIBUTES.indexOf(aname) !== -1; const insertDomLines = (nodeToAddAfter, infoStructs) => { let lastEntry; let lineStartOffset; if (infoStructs.length < 1) return; infoStructs.forEach((info) => { const p2 = PROFILER('insertLine', false); // eslint-disable-line new-cap const node = info.node; const key = uniqueId(node); let entry; p2.mark('findEntry'); if (lastEntry) { // optimization to avoid recalculation const next = rep.lines.next(lastEntry); if (next && next.key === key) { entry = next; lineStartOffset += lastEntry.width; } } if (!entry) { p2.literal(1, 'nonopt'); entry = rep.lines.atKey(key); lineStartOffset = rep.lines.offsetOfKey(key); } else { p2.literal(0, 'nonopt'); } lastEntry = entry; p2.mark('spans'); getSpansForLine(entry, (tokenText, tokenClass) => { info.appendSpan(tokenText, tokenClass); }, lineStartOffset); p2.mark('addLine'); info.prepareForAdd(); entry.lineMarker = info.lineMarker; if (!nodeToAddAfter) { root.insertBefore(node, root.firstChild); } else { root.insertBefore(node, nodeToAddAfter.nextSibling); } nodeToAddAfter = node; info.notifyAdded(); p2.mark('markClean'); markNodeClean(node); p2.end(); }); }; const isCaret = () => ( rep.selStart && rep.selEnd && rep.selStart[0] === rep.selEnd[0] && rep.selStart[1] === rep.selEnd[1] ); editorInfo.ace_isCaret = isCaret; // prereq: isCaret() const caretLine = () => rep.selStart[0]; editorInfo.ace_caretLine = caretLine; const caretColumn = () => rep.selStart[1]; editorInfo.ace_caretColumn = caretColumn; const caretDocChar = () => rep.lines.offsetOfIndex(caretLine()) + caretColumn(); editorInfo.ace_caretDocChar = caretDocChar; const handleReturnIndentation = () => { // on return, indent to level of previous line if (isCaret() && caretColumn() === 0 && caretLine() > 0) { const lineNum = caretLine(); const thisLine = rep.lines.atIndex(lineNum); const prevLine = rep.lines.prev(thisLine); const prevLineText = prevLine.text; let theIndent = /^ *(?:)/.exec(prevLineText)[0]; const shouldIndent = parent.parent.clientVars.indentationOnNewLine; if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { theIndent += THE_TAB; } const cs = Changeset.builder(rep.lines.totalWidth()).keep( rep.lines.offsetOfIndex(lineNum), lineNum).insert( theIndent, [ ['author', thisAuthor], ], rep.apool).toString(); performDocumentApplyChangeset(cs); performSelectionChange([lineNum, theIndent.length], [lineNum, theIndent.length]); } }; const getPointForLineAndChar = (lineAndChar) => { const line = lineAndChar[0]; let charsLeft = lineAndChar[1]; // Do not uncomment this in production it will break iFrames. // top.console.log("line: %d, key: %s, node: %o", line, rep.lines.atIndex(line).key, // getCleanNodeByKey(rep.lines.atIndex(line).key)); const lineEntry = rep.lines.atIndex(line); charsLeft -= lineEntry.lineMarker; if (charsLeft < 0) { charsLeft = 0; } const lineNode = lineEntry.lineNode; let n = lineNode; let after = false; if (charsLeft === 0) { return { node: lineNode, index: 0, maxIndex: 1, }; } while (!(n === lineNode && after)) { if (after) { if (n.nextSibling) { n = n.nextSibling; after = false; } else { n = n.parentNode; } } else if (isNodeText(n)) { const len = n.nodeValue.length; if (charsLeft <= len) { return { node: n, index: charsLeft, maxIndex: len, }; } charsLeft -= len; after = true; } else if (n.firstChild) { n = n.firstChild; } else { after = true; } } return { node: lineNode, index: 1, maxIndex: 1, }; }; const nodeText = (n) => n.textContent || n.nodeValue || ''; const getLineAndCharForPoint = (point) => { // Turn DOM node selection into [line,char] selection. // This method has to work when the DOM is not pristine, // assuming the point is not in a dirty node. if (point.node === root) { if (point.index === 0) { return [0, 0]; } else { const N = rep.lines.length(); const ln = rep.lines.atIndex(N - 1); return [N - 1, ln.text.length]; } } else { let n = point.node; let col = 0; // if this part fails, it probably means the selection node // was dirty, and we didn't see it when collecting dirty nodes. if (isNodeText(n)) { col = point.index; } else if (point.index > 0) { col = nodeText(n).length; } let parNode, prevSib; while ((parNode = n.parentNode) !== root) { if ((prevSib = n.previousSibling)) { n = prevSib; col += nodeText(n).length; } else { n = parNode; } } if (n.firstChild && isBlockElement(n.firstChild)) { col += 1; // lineMarker } const lineEntry = rep.lines.atKey(n.id); const lineNum = rep.lines.indexOfEntry(lineEntry); return [lineNum, col]; } }; editorInfo.ace_getLineAndCharForPoint = getLineAndCharForPoint; const createDomLineEntry = (lineString) => { const info = doCreateDomLine(lineString.length > 0); const newNode = info.node; return { key: uniqueId(newNode), text: lineString, lineNode: newNode, domInfo: info, lineMarker: 0, }; }; const performDocumentApplyChangeset = (changes, insertsAfterSelection) => { const domAndRepSplice = (startLine, deleteCount, newLineStrings) => { const keysToDelete = []; if (deleteCount > 0) { let entryToDelete = rep.lines.atIndex(startLine); for (let i = 0; i < deleteCount; i++) { keysToDelete.push(entryToDelete.key); entryToDelete = rep.lines.next(entryToDelete); } } const lineEntries = newLineStrings.map(createDomLineEntry); doRepLineSplice(startLine, deleteCount, lineEntries); let nodeToAddAfter; if (startLine > 0) { nodeToAddAfter = getCleanNodeByKey(rep.lines.atIndex(startLine - 1).key); } else { nodeToAddAfter = null; } insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo)); keysToDelete.forEach((k) => { const n = doc.getElementById(k); n.parentNode.removeChild(n); }); if ( (rep.selStart && rep.selStart[0] >= startLine && rep.selStart[0] <= startLine + deleteCount) || (rep.selEnd && rep.selEnd[0] >= startLine && rep.selEnd[0] <= startLine + deleteCount)) { currentCallStack.selectionAffected = true; } }; doRepApplyChangeset(changes, insertsAfterSelection); let requiredSelectionSetting = null; if (rep.selStart && rep.selEnd) { const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; const result = Changeset.characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection); requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart]; } const linesMutatee = { // TODO: Rhansen to check usage of args here. splice: (start, numRemoved, ...args) => { domAndRepSplice(start, numRemoved, args.map((s) => s.slice(0, -1))); }, get: (i) => `${rep.lines.atIndex(i).text}\n`, length: () => rep.lines.length(), }; Changeset.mutateTextLines(changes, linesMutatee); if (requiredSelectionSetting) { performSelectionChange( lineAndColumnFromChar( requiredSelectionSetting[0] ), lineAndColumnFromChar(requiredSelectionSetting[1]), requiredSelectionSetting[2] ); } }; const doRepApplyChangeset = (changes, insertsAfterSelection) => { Changeset.checkRep(changes); if (Changeset.oldLen(changes) !== rep.alltext.length) { const errMsg = `${Changeset.oldLen(changes)}/${rep.alltext.length}`; throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`); } // (function doRecordUndoInformation(changes) { ((changes) => { const editEvent = currentCallStack.editEvent; if (editEvent.eventType === 'nonundoable') { if (!editEvent.changeset) { editEvent.changeset = changes; } else { editEvent.changeset = Changeset.compose(editEvent.changeset, changes, rep.apool); } } else { const inverseChangeset = Changeset.inverse(changes, { get: (i) => `${rep.lines.atIndex(i).text}\n`, length: () => rep.lines.length(), }, rep.alines, rep.apool); if (!editEvent.backset) { editEvent.backset = inverseChangeset; } else { editEvent.backset = Changeset.compose(inverseChangeset, editEvent.backset, rep.apool); } } })(changes); // rep.alltext = Changeset.applyToText(changes, rep.alltext); Changeset.mutateAttributionLines(changes, rep.alines, rep.apool); if (changesetTracker.isTracking()) { changesetTracker.composeUserChangeset(changes); } }; /* Converts the position of a char (index in String) into a [row, col] tuple */ const lineAndColumnFromChar = (x) => { const lineEntry = rep.lines.atOffset(x); const lineStart = rep.lines.offsetOfEntry(lineEntry); const lineNum = rep.lines.indexOfEntry(lineEntry); return [lineNum, x - lineStart]; }; const performDocumentReplaceCharRange = (startChar, endChar, newText) => { if (startChar === endChar && newText.length === 0) { return; } // Requires that the replacement preserve the property that the // internal document text ends in a newline. Given this, we // rewrite the splice so that it doesn't touch the very last // char of the document. if (endChar === rep.alltext.length) { if (startChar === endChar) { // an insert at end startChar--; endChar--; newText = `\n${newText.substring(0, newText.length - 1)}`; } else if (newText.length === 0) { // a delete at end startChar--; endChar--; } else { // a replace at end endChar--; newText = newText.substring(0, newText.length - 1); } } performDocumentReplaceRange(lineAndColumnFromChar(startChar), lineAndColumnFromChar(endChar), newText); }; const performDocumentApplyAttributesToCharRange = (start, end, attribs) => { end = Math.min(end, rep.alltext.length - 1); documentAttributeManager.setAttributesOnRange( lineAndColumnFromChar(start), lineAndColumnFromChar(end), attribs); }; editorInfo.ace_performDocumentApplyAttributesToCharRange = performDocumentApplyAttributesToCharRange; const setAttributeOnSelection = (attributeName, attributeValue) => { if (!(rep.selStart && rep.selEnd)) return; documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [ [attributeName, attributeValue], ]); }; editorInfo.ace_setAttributeOnSelection = setAttributeOnSelection; const getAttributeOnSelection = (attributeName, prevChar) => { if (!(rep.selStart && rep.selEnd)) return; const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); if (isNotSelection) { if (prevChar) { // If it's not the start of the line if (rep.selStart[1] !== 0) { rep.selStart[1]--; } } } const withIt = Changeset.makeAttribsString('+', [ [attributeName, 'true'], ], rep.apool); const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); const hasIt = (attribs) => withItRegex.test(attribs); const rangeHasAttrib = (selStart, selEnd) => { // if range is collapsed -> no attribs in range if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; if (selStart[0] !== selEnd[0]) { // -> More than one line selected let hasAttrib = true; // from selStart to the end of the first line hasAttrib = hasAttrib && rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); // for all lines in between for (let n = selStart[0] + 1; n < selEnd[0]; n++) { hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); } // for the last, potentially partial, line hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); return hasAttrib; } // Logic tells us we now have a range on a single line const lineNum = selStart[0]; const start = selStart[1]; const end = selEnd[1]; let hasAttrib = true; // Iterate over attribs on this line const opIter = Changeset.opIterator(rep.alines[lineNum]); let indexIntoLine = 0; while (opIter.hasNext()) { const op = opIter.next(); const opStartInLine = indexIntoLine; const opEndInLine = opStartInLine + op.chars; if (!hasIt(op.attribs)) { // does op overlap selection? if (!(opEndInLine <= start || opStartInLine >= end)) { // since it's overlapping but hasn't got the attrib -> range hasn't got it hasAttrib = false; break; } } indexIntoLine = opEndInLine; } return hasAttrib; }; return rangeHasAttrib(rep.selStart, rep.selEnd); }; editorInfo.ace_getAttributeOnSelection = getAttributeOnSelection; const toggleAttributeOnSelection = (attributeName) => { if (!(rep.selStart && rep.selEnd)) return; let selectionAllHasIt = true; const withIt = Changeset.makeAttribsString('+', [ [attributeName, 'true'], ], rep.apool); const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); const hasIt = (attribs) => withItRegex.test(attribs); const selStartLine = rep.selStart[0]; const selEndLine = rep.selEnd[0]; for (let n = selStartLine; n <= selEndLine; n++) { const opIter = Changeset.opIterator(rep.alines[n]); let indexIntoLine = 0; let selectionStartInLine = 0; if (documentAttributeManager.lineHasMarker(n)) { selectionStartInLine = 1; // ignore "*" used as line marker } let selectionEndInLine = rep.lines.atIndex(n).text.length; // exclude newline if (n === selStartLine) { selectionStartInLine = rep.selStart[1]; } if (n === selEndLine) { selectionEndInLine = rep.selEnd[1]; } while (opIter.hasNext()) { const op = opIter.next(); const opStartInLine = indexIntoLine; const opEndInLine = opStartInLine + op.chars; if (!hasIt(op.attribs)) { // does op overlap selection? if (!(opEndInLine <= selectionStartInLine || opStartInLine >= selectionEndInLine)) { selectionAllHasIt = false; break; } } indexIntoLine = opEndInLine; } if (!selectionAllHasIt) { break; } } const attributeValue = selectionAllHasIt ? '' : 'true'; documentAttributeManager.setAttributesOnRange( rep.selStart, rep.selEnd, [[attributeName, attributeValue]] ); if (attribIsFormattingStyle(attributeName)) { updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ... } }; editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection; const performDocumentReplaceSelection = (newText) => { if (!(rep.selStart && rep.selEnd)) return; performDocumentReplaceRange(rep.selStart, rep.selEnd, newText); }; // Change the abstract representation of the document to have a different set of lines. // Must be called after rep.alltext is set. const doRepLineSplice = (startLine, deleteCount, newLineEntries) => { newLineEntries.forEach((entry) => { entry.width = entry.text.length + 1; }); const startOldChar = rep.lines.offsetOfIndex(startLine); const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); rep.lines.splice(startLine, deleteCount, newLineEntries); currentCallStack.docTextChanged = true; currentCallStack.repChanged = true; const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); rep.alltext = rep.alltext.substring(0, startOldChar) + newText + rep.alltext.substring(endOldChar, rep.alltext.length); }; const doIncorpLineSplice = (startLine, deleteCount, newLineEntries, lineAttribs, hints) => { const startOldChar = rep.lines.offsetOfIndex(startLine); const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); const oldRegionStart = rep.lines.offsetOfIndex(startLine); let selStartHintChar, selEndHintChar; if (hints && hints.selStart) { selStartHintChar = rep.lines.offsetOfIndex(hints.selStart[0]) + hints.selStart[1] - oldRegionStart; } if (hints && hints.selEnd) { selEndHintChar = rep.lines.offsetOfIndex(hints.selEnd[0]) + hints.selEnd[1] - oldRegionStart; } const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); const oldText = rep.alltext.substring(startOldChar, endOldChar); const oldAttribs = rep.alines.slice(startLine, startLine + deleteCount).join(''); const newAttribs = `${lineAttribs.join('|1+1')}|1+1`; // not valid in a changeset const analysis = analyzeChange( oldText, newText, oldAttribs, newAttribs, selStartHintChar, selEndHintChar ); const commonStart = analysis[0]; let commonEnd = analysis[1]; let shortOldText = oldText.substring(commonStart, oldText.length - commonEnd); let shortNewText = newText.substring(commonStart, newText.length - commonEnd); let spliceStart = startOldChar + commonStart; let spliceEnd = endOldChar - commonEnd; let shiftFinalNewlineToBeforeNewText = false; // adjust the splice to not involve the final newline of the document; // be very defensive if (shortOldText.charAt(shortOldText.length - 1) === '\n' && shortNewText.charAt(shortNewText.length - 1) === '\n') { // replacing text that ends in newline with text that also ends in newline // (still, after analysis, somehow) shortOldText = shortOldText.slice(0, -1); shortNewText = shortNewText.slice(0, -1); spliceEnd--; commonEnd++; } if (shortOldText.length === 0 && spliceStart === rep.alltext.length && shortNewText.length > 0) { // inserting after final newline, bad spliceStart--; spliceEnd--; shortNewText = `\n${shortNewText.slice(0, -1)}`; shiftFinalNewlineToBeforeNewText = true; } if (spliceEnd === rep.alltext.length && shortOldText.length > 0 && shortNewText.length === 0) { // deletion at end of rep.alltext if (rep.alltext.charAt(spliceStart - 1) === '\n') { // (if not then what the heck? it will definitely lead // to a rep.alltext without a final newline) spliceStart--; spliceEnd--; } } if (!(shortOldText.length === 0 && shortNewText.length === 0)) { const oldDocText = rep.alltext; const oldLen = oldDocText.length; const spliceStartLine = rep.lines.indexOfOffset(spliceStart); const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine); const startBuilder = () => { const builder = Changeset.builder(oldLen); builder.keep(spliceStartLineStart, spliceStartLine); builder.keep(spliceStart - spliceStartLineStart); return builder; }; const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => { const attribsIter = Changeset.opIterator(attribs); let textIndex = 0; const newTextStart = commonStart; const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0); while (attribsIter.hasNext()) { const op = attribsIter.next(); const nextIndex = textIndex + op.chars; if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); } textIndex = nextIndex; } }; const justApplyStyles = (shortNewText === shortOldText); let theChangeset; if (justApplyStyles) { // create changeset that clears the incorporated styles on // the existing text. we compose this with the // changeset the applies the styles found in the DOM. // This allows us to incorporate, e.g., Safari's native "unbold". const incorpedAttribClearer = cachedStrFunc( (oldAtts) => Changeset.mapAttribNumbers(oldAtts, (n) => { const k = rep.apool.getAttribKey(n); if (isStyleAttribute(k)) { return rep.apool.putAttrib([k, '']); } return false; } ) ); const builder1 = startBuilder(); if (shiftFinalNewlineToBeforeNewText) { builder1.keep(1, 1); } eachAttribRun(oldAttribs, (start, end, attribs) => { builder1.keepText(newText.substring(start, end), incorpedAttribClearer(attribs)); }); const clearer = builder1.toString(); const builder2 = startBuilder(); if (shiftFinalNewlineToBeforeNewText) { builder2.keep(1, 1); } eachAttribRun(newAttribs, (start, end, attribs) => { builder2.keepText(newText.substring(start, end), attribs); }); const styler = builder2.toString(); theChangeset = Changeset.compose(clearer, styler, rep.apool); } else { const builder = startBuilder(); const spliceEndLine = rep.lines.indexOfOffset(spliceEnd); const spliceEndLineStart = rep.lines.offsetOfIndex(spliceEndLine); if (spliceEndLineStart > spliceStart) { builder.remove(spliceEndLineStart - spliceStart, spliceEndLine - spliceStartLine); builder.remove(spliceEnd - spliceEndLineStart); } else { builder.remove(spliceEnd - spliceStart); } let isNewTextMultiauthor = false; const authorAtt = Changeset.makeAttribsString('+', (thisAuthor ? [ ['author', thisAuthor], ] : []), rep.apool); const authorizer = cachedStrFunc((oldAtts) => { if (isNewTextMultiauthor) { // prefer colors from DOM return Changeset.composeAttributes(authorAtt, oldAtts, true, rep.apool); } else { // use this author's color return Changeset.composeAttributes(oldAtts, authorAtt, true, rep.apool); } }); let foundDomAuthor = ''; eachAttribRun(newAttribs, (start, end, attribs) => { const a = Changeset.attribsAttributeValue(attribs, 'author', rep.apool); if (a && a !== foundDomAuthor) { if (!foundDomAuthor) { foundDomAuthor = a; } else { isNewTextMultiauthor = true; // multiple authors in DOM! } } }); if (shiftFinalNewlineToBeforeNewText) { builder.insert('\n', authorizer('')); } eachAttribRun(newAttribs, (start, end, attribs) => { builder.insert(newText.substring(start, end), authorizer(attribs)); }); theChangeset = builder.toString(); } // dmesg(htmlPrettyEscape(theChangeset)); doRepApplyChangeset(theChangeset); } // do this no matter what, because we need to get the right // line keys into the rep. doRepLineSplice(startLine, deleteCount, newLineEntries); }; const cachedStrFunc = (func) => { const cache = {}; return (s) => { if (!cache[s]) { cache[s] = func(s); } return cache[s]; }; }; const analyzeChange = ( oldText, newText, oldAttribs, newAttribs, optSelStartHint, optSelEndHint) => { // we need to take into account both the styles attributes & attributes defined by // the plugins, so basically we can ignore only the default line attribs used by // Etherpad const incorpedAttribFilter = (anum) => !isDefaultLineAttribute(rep.apool.getAttribKey(anum)); const attribRuns = (attribs) => { const lengs = []; const atts = []; const iter = Changeset.opIterator(attribs); while (iter.hasNext()) { const op = iter.next(); lengs.push(op.chars); atts.push(op.attribs); } return [lengs, atts]; }; const attribIterator = (runs, backward) => { const lengs = runs[0]; const atts = runs[1]; let i = (backward ? lengs.length - 1 : 0); let j = 0; const next = () => { while (j >= lengs[i]) { if (backward) i--; else i++; j = 0; } const a = atts[i]; j++; return a; }; return next; }; const oldLen = oldText.length; const newLen = newText.length; const minLen = Math.min(oldLen, newLen); const oldARuns = attribRuns(Changeset.filterAttribNumbers(oldAttribs, incorpedAttribFilter)); const newARuns = attribRuns(Changeset.filterAttribNumbers(newAttribs, incorpedAttribFilter)); let commonStart = 0; const oldStartIter = attribIterator(oldARuns, false); const newStartIter = attribIterator(newARuns, false); while (commonStart < minLen) { if (oldText.charAt(commonStart) === newText.charAt(commonStart) && oldStartIter() === newStartIter()) { commonStart++; } else { break; } } let commonEnd = 0; const oldEndIter = attribIterator(oldARuns, true); const newEndIter = attribIterator(newARuns, true); while (commonEnd < minLen) { if (commonEnd === 0) { // assume newline in common oldEndIter(); newEndIter(); commonEnd++; } else if ( oldText.charAt(oldLen - 1 - commonEnd) === newText.charAt(newLen - 1 - commonEnd) && oldEndIter() === newEndIter()) { commonEnd++; } else { break; } } let hintedCommonEnd = -1; if ((typeof optSelEndHint) === 'number') { hintedCommonEnd = newLen - optSelEndHint; } if (commonStart + commonEnd > oldLen) { // ambiguous insertion const minCommonEnd = oldLen - commonStart; const maxCommonEnd = commonEnd; if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { commonEnd = hintedCommonEnd; } else { commonEnd = minCommonEnd; } commonStart = oldLen - commonEnd; } if (commonStart + commonEnd > newLen) { // ambiguous deletion const minCommonEnd = newLen - commonStart; const maxCommonEnd = commonEnd; if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { commonEnd = hintedCommonEnd; } else { commonEnd = minCommonEnd; } commonStart = newLen - commonEnd; } return [commonStart, commonEnd]; }; const equalLineAndChars = (a, b) => { if (!a) return !b; if (!b) return !a; return (a[0] === b[0] && a[1] === b[1]); }; const performSelectionChange = (selectStart, selectEnd, focusAtStart) => { if (repSelectionChange(selectStart, selectEnd, focusAtStart)) { currentCallStack.selectionAffected = true; } }; editorInfo.ace_performSelectionChange = performSelectionChange; // Change the abstract representation of the document to have a different selection. // Should not rely on the line representation. Should not affect the DOM. const repSelectionChange = (selectStart, selectEnd, focusAtStart) => { focusAtStart = !!focusAtStart; const newSelFocusAtStart = (focusAtStart && ((!selectStart) || (!selectEnd) || (selectStart[0] !== selectEnd[0]) || (selectStart[1] !== selectEnd[1]))); if ((!equalLineAndChars(rep.selStart, selectStart)) || (!equalLineAndChars(rep.selEnd, selectEnd)) || (rep.selFocusAtStart !== newSelFocusAtStart)) { rep.selStart = selectStart; rep.selEnd = selectEnd; rep.selFocusAtStart = newSelFocusAtStart; currentCallStack.repChanged = true; // select the formatting buttons when there is the style applied on selection selectFormattingButtonIfLineHasStyleApplied(rep); hooks.callAll('aceSelectionChanged', { rep, callstack: currentCallStack, documentAttributeManager, }); // we scroll when user places the caret at the last line of the pad // when this settings is enabled const docTextChanged = currentCallStack.docTextChanged; if (!docTextChanged) { const isScrollableEvent = !isPadLoading(currentCallStack.type) && isScrollableEditEvent(currentCallStack.type); const innerHeight = getInnerHeight(); scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary( rep, isScrollableEvent, innerHeight ); } return true; // Do not uncomment this in production it will break iFrames. // top.console.log("selStart: %o, selEnd: %o, focusAtStart: %s", rep.selStart, rep.selEnd, // String(!!rep.selFocusAtStart)); } return false; // Do not uncomment this in production it will break iFrames. // top.console.log("%o %o %s", rep.selStart, rep.selEnd, rep.selFocusAtStart); }; const isPadLoading = (eventType) => ( eventType === 'setup') || (eventType === 'setBaseText') || (eventType === 'importText' ); const updateStyleButtonState = (attribName, hasStyleOnRepSelection) => { const $formattingButton = parent.parent.$(`[data-key="${attribName}"]`).find('a'); $formattingButton.toggleClass(SELECT_BUTTON_CLASS, hasStyleOnRepSelection); }; const attribIsFormattingStyle = (attribName) => FORMATTING_STYLES.indexOf(attribName) !== -1; const selectFormattingButtonIfLineHasStyleApplied = (rep) => { FORMATTING_STYLES.forEach((style) => { const hasStyleOnRepSelection = documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style); updateStyleButtonState(style, hasStyleOnRepSelection); }); }; const doCreateDomLine = (nonEmpty) => domline.createDomLine(nonEmpty, doesWrap, browser, doc); const textify = (str) => str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' '); const _blockElems = { div: 1, p: 1, pre: 1, li: 1, ol: 1, ul: 1, }; hooks.callAll('aceRegisterBlockElements').forEach((element) => { _blockElems[element] = 1; }); const isBlockElement = (n) => !!_blockElems[(n.tagName || '').toLowerCase()]; editorInfo.ace_isBlockElement = isBlockElement; const getDirtyRanges = () => { // based on observedChanges, return a list of ranges of original lines // that need to be removed or replaced with new user content to incorporate // the user's changes into the line representation. ranges may be zero-length, // indicating inserted content. for example, [0,0] means content was inserted // at the top of the document, while [3,4] means line 3 was deleted, modified, // or replaced with one or more new lines of content. ranges do not touch. const p = PROFILER('getDirtyRanges', false); // eslint-disable-line new-cap p.forIndices = 0; p.consecutives = 0; p.corrections = 0; const cleanNodeForIndexCache = {}; const N = rep.lines.length(); // old number of lines const cleanNodeForIndex = (i) => { // if line (i) in the un-updated line representation maps to a clean node // in the document, return that node. // if (i) is out of bounds, return true. else return false. if (cleanNodeForIndexCache[i] === undefined) { p.forIndices++; let result; if (i < 0 || i >= N) { result = true; // truthy, but no actual node } else { const key = rep.lines.atIndex(i).key; result = (getCleanNodeByKey(key) || false); } cleanNodeForIndexCache[i] = result; } return cleanNodeForIndexCache[i]; }; const isConsecutiveCache = {}; const isConsecutive = (i) => { if (isConsecutiveCache[i] === undefined) { p.consecutives++; isConsecutiveCache[i] = (() => { // returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes, // or document boundaries, are consecutive in the changed DOM const a = cleanNodeForIndex(i - 1); const b = cleanNodeForIndex(i); if ((!a) || (!b)) return false; // violates precondition if ((a === true) && (b === true)) return !root.firstChild; if ((a === true) && b.previousSibling) return false; if ((b === true) && a.nextSibling) return false; if ((a === true) || (b === true)) return true; return a.nextSibling === b; })(); } return isConsecutiveCache[i]; }; // returns whether line (i) in the un-updated representation maps to a clean node, // or is outside the bounds of the document const isClean = (i) => !!cleanNodeForIndex(i); // list of pairs, each representing a range of lines that is clean and consecutive // in the changed DOM. lines (-1) and (N) are always clean, but may or may not // be consecutive with lines in the document. pairs are in sorted order. const cleanRanges = [ [-1, N + 1], ]; const rangeForLine = (i) => { // returns index of cleanRange containing i, or -1 if none let answer = -1; cleanRanges.forEach((r, idx) => { if (i >= r[1]) return false; // keep looking if (i < r[0]) return true; // not found, stop looking answer = idx; return true; // found, stop looking }); return answer; }; const removeLineFromRange = (rng, line) => { // rng is index into cleanRanges, line is line number // precond: line is in rng const a = cleanRanges[rng][0]; const b = cleanRanges[rng][1]; if ((a + 1) === b) cleanRanges.splice(rng, 1); else if (line === a) cleanRanges[rng][0]++; else if (line === (b - 1)) cleanRanges[rng][1]--; else cleanRanges.splice(rng, 1, [a, line], [line + 1, b]); }; const splitRange = (rng, pt) => { // precond: pt splits cleanRanges[rng] into two non-empty ranges const a = cleanRanges[rng][0]; const b = cleanRanges[rng][1]; cleanRanges.splice(rng, 1, [a, pt], [pt, b]); }; const correctedLines = {}; const correctlyAssignLine = (line) => { if (correctedLines[line]) return true; p.corrections++; correctedLines[line] = true; // "line" is an index of a line in the un-updated rep. // returns whether line was already correctly assigned (i.e. correctly // clean or dirty, according to cleanRanges, and if clean, correctly // attached or not attached (i.e. in the same range as) the prev and next lines). const rng = rangeForLine(line); const lineClean = isClean(line); if (rng < 0) { if (lineClean) { // somehow lost clean line } return true; } if (!lineClean) { // a clean-range includes this dirty line, fix it removeLineFromRange(rng, line); return false; } else { // line is clean, but could be wrongly connected to a clean line // above or below const a = cleanRanges[rng][0]; const b = cleanRanges[rng][1]; let didSomething = false; // we'll leave non-clean adjacent nodes in the clean range for the caller to // detect and deal with. we deal with whether the range should be split // just above or just below this line. if (a < line && isClean(line - 1) && !isConsecutive(line)) { splitRange(rng, line); didSomething = true; } if (b > (line + 1) && isClean(line + 1) && !isConsecutive(line + 1)) { splitRange(rng, line + 1); didSomething = true; } return !didSomething; } }; const detectChangesAroundLine = (line, reqInARow) => { // make sure cleanRanges is correct about line number "line" and the surrounding // lines; only stops checking at end of document or after no changes need // making for several consecutive lines. note that iteration is over old lines, // so this operation takes time proportional to the number of old lines // that are changed or missing, not the number of new lines inserted. let correctInARow = 0; let currentIndex = line; while (correctInARow < reqInARow && currentIndex >= 0) { if (correctlyAssignLine(currentIndex)) { correctInARow++; } else { correctInARow = 0; } currentIndex--; } correctInARow = 0; currentIndex = line; while (correctInARow < reqInARow && currentIndex < N) { if (correctlyAssignLine(currentIndex)) { correctInARow++; } else { correctInARow = 0; } currentIndex++; } }; if (N === 0) { p.cancel(); if (!isConsecutive(0)) { splitRange(0, 0); } } else { p.mark('topbot'); detectChangesAroundLine(0, 1); detectChangesAroundLine(N - 1, 1); p.mark('obs'); for (const k in observedChanges.cleanNodesNearChanges) { if (observedChanges.cleanNodesNearChanges[k]) { const key = k.substring(1); if (rep.lines.containsKey(key)) { const line = rep.lines.indexOfKey(key); detectChangesAroundLine(line, 2); } } } p.mark('stats&calc'); p.literal(p.forIndices, 'byidx'); p.literal(p.consecutives, 'cons'); p.literal(p.corrections, 'corr'); } const dirtyRanges = []; for (let r = 0; r < cleanRanges.length - 1; r++) { dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]); } p.end(); return dirtyRanges; }; const markNodeClean = (n) => { // clean nodes have knownHTML that matches their innerHTML const dirtiness = {}; dirtiness.nodeId = uniqueId(n); dirtiness.knownHTML = n.innerHTML; setAssoc(n, 'dirtiness', dirtiness); }; const isNodeDirty = (n) => { const p = PROFILER('cleanCheck', false); // eslint-disable-line new-cap if (n.parentNode !== root) return true; const data = getAssoc(n, 'dirtiness'); if (!data) return true; if (n.id !== data.nodeId) return true; if (n.innerHTML !== data.knownHTML) return true; p.end(); return false; }; const handleClick = (evt) => { inCallStackIfNecessary('handleClick', () => { idleWorkTimer.atMost(200); }); const isLink = (n) => (n.tagName || '').toLowerCase() === 'a' && n.href; // only want to catch left-click if ((!evt.ctrlKey) && (evt.button !== 2) && (evt.button !== 3)) { // find A tag with HREF let n = evt.target; while (n && n.parentNode && !isLink(n)) { n = n.parentNode; } if (n && isLink(n)) { try { window.open(n.href, '_blank', 'noopener,noreferrer'); } catch (e) { // absorb "user canceled" error in IE for certain prompts } evt.preventDefault(); } } hideEditBarDropdowns(); }; const hideEditBarDropdowns = () => { if (window.parent.parent.padeditbar) { // required in case its in an iframe should probably use parent.. See Issue 327 https://github.com/ether/etherpad-lite/issues/327 window.parent.parent.padeditbar.toggleDropDown('none'); } }; const renumberList = (lineNum) => { // 1-check we are in a list let type = getLineListType(lineNum); if (!type) { return null; } type = /([a-z]+)[0-9]+/.exec(type); if (type[1] === 'indent') { return null; } // 2-find the first line of the list while (lineNum - 1 >= 0 && (type = getLineListType(lineNum - 1))) { type = /([a-z]+)[0-9]+/.exec(type); if (type[1] === 'indent') break; lineNum--; } // 3-renumber every list item of the same level from the beginning, level 1 // IMPORTANT: never skip a level because there imbrication may be arbitrary const builder = Changeset.builder(rep.lines.totalWidth()); let loc = [0, 0]; const applyNumberList = (line, level) => { // init let position = 1; let curLevel = level; let listType; // loop over the lines while ((listType = getLineListType(line))) { // apply new num listType = /([a-z]+)([0-9]+)/.exec(listType); curLevel = Number(listType[2]); if (isNaN(curLevel) || listType[0] === 'indent') { return line; } else if (curLevel === level) { ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 0])); ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 1]), [ ['start', position], ], rep.apool); position++; line++; } else if (curLevel < level) { return line;// back to parent } else { line = applyNumberList(line, level + 1);// recursive call } } return line; }; applyNumberList(lineNum, 1); const cs = builder.toString(); if (!Changeset.isIdentity(cs)) { performDocumentApplyChangeset(cs); } // 4-apply the modifications }; editorInfo.ace_renumberList = renumberList; const setLineListType = (lineNum, listType) => { if (listType === '') { documentAttributeManager.removeAttributeOnLine(lineNum, listAttributeName); documentAttributeManager.removeAttributeOnLine(lineNum, 'start'); } else { documentAttributeManager.setAttributeOnLine(lineNum, listAttributeName, listType); } // if the list has been removed, it is necessary to renumber // starting from the *next* line because the list may have been // separated. If it returns null, it means that the list was not cut, try // from the current one. if (renumberList(lineNum + 1) == null) { renumberList(lineNum); } }; const doReturnKey = () => { if (!(rep.selStart && rep.selEnd)) { return; } const lineNum = rep.selStart[0]; let listType = getLineListType(lineNum); if (listType) { const text = rep.lines.atIndex(lineNum).text; listType = /([a-z]+)([0-9]+)/.exec(listType); const type = listType[1]; const level = Number(listType[2]); // detect empty list item; exclude indentation if (text === '*' && type !== 'indent') { // if not already on the highest level if (level > 1) { setLineListType(lineNum, type + (level - 1));// automatically decrease the level } else { setLineListType(lineNum, '');// remove the list renumberList(lineNum + 1);// trigger renumbering of list that may be right after } } else if (lineNum + 1 <= rep.lines.length()) { performDocumentReplaceSelection('\n'); setLineListType(lineNum + 1, type + level); } } else { performDocumentReplaceSelection('\n'); handleReturnIndentation(); } }; editorInfo.ace_doReturnKey = doReturnKey; const doIndentOutdent = (isOut) => { if (!((rep.selStart && rep.selEnd) || ((rep.selStart[0] === rep.selEnd[0]) && (rep.selStart[1] === rep.selEnd[1]) && rep.selEnd[1] > 1)) && (isOut !== true) ) { return false; } const firstLine = rep.selStart[0]; const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0)); const mods = []; for (let n = firstLine; n <= lastLine; n++) { let listType = getLineListType(n); let t = 'indent'; let level = 0; if (listType) { listType = /([a-z]+)([0-9]+)/.exec(listType); if (listType) { t = listType[1]; level = Number(listType[2]); } } const newLevel = Math.max(0, Math.min(MAX_LIST_LEVEL, level + (isOut ? -1 : 1))); if (level !== newLevel) { mods.push([n, (newLevel > 0) ? t + newLevel : '']); } } mods.forEach((mod) => { setLineListType(mod[0], mod[1]); }); return true; }; editorInfo.ace_doIndentOutdent = doIndentOutdent; const doTabKey = (shiftDown) => { if (!doIndentOutdent(shiftDown)) { performDocumentReplaceSelection(THE_TAB); } }; const doDeleteKey = (optEvt) => { const evt = optEvt || {}; let handled = false; if (rep.selStart) { if (isCaret()) { const lineNum = caretLine(); const col = caretColumn(); const lineEntry = rep.lines.atIndex(lineNum); const lineText = lineEntry.text; const lineMarker = lineEntry.lineMarker; if (/^ +$/.exec(lineText.substring(lineMarker, col))) { const col2 = col - lineMarker; const tabSize = THE_TAB.length; const toDelete = ((col2 - 1) % tabSize) + 1; performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], ''); // scrollSelectionIntoView(); handled = true; } } if (!handled) { if (isCaret()) { const theLine = caretLine(); const lineEntry = rep.lines.atIndex(theLine); if (caretColumn() <= lineEntry.lineMarker) { // delete at beginning of line const prevLineListType = (theLine > 0 ? getLineListType(theLine - 1) : ''); const thisLineListType = getLineListType(theLine); const prevLineEntry = (theLine > 0 && rep.lines.atIndex(theLine - 1)); const prevLineBlank = (prevLineEntry && prevLineEntry.text.length === prevLineEntry.lineMarker); const thisLineHasMarker = documentAttributeManager.lineHasMarker(theLine); if (thisLineListType) { // this line is a list if (prevLineBlank && !prevLineListType) { // previous line is blank, remove it performDocumentReplaceRange( [theLine - 1, prevLineEntry.text.length], [theLine, 0], '' ); } else { // delistify performDocumentReplaceRange([theLine, 0], [theLine, lineEntry.lineMarker], ''); } } else if (thisLineHasMarker && prevLineEntry) { // If the line has any attributes assigned, remove them by removing the marker '*' performDocumentReplaceRange( [theLine - 1, prevLineEntry.text.length], [theLine, lineEntry.lineMarker], '' ); } else if (theLine > 0) { // remove newline performDocumentReplaceRange( [theLine - 1, prevLineEntry.text.length], [theLine, 0], '' ); } } else { const docChar = caretDocChar(); if (docChar > 0) { if (evt.metaKey || evt.ctrlKey || evt.altKey) { // delete as many unicode "letters or digits" in a row as possible; // always delete one char, delete further even if that first char // isn't actually a word char. let deleteBackTo = docChar - 1; while (deleteBackTo > lineEntry.lineMarker && isWordChar(rep.alltext.charAt(deleteBackTo - 1))) { deleteBackTo--; } performDocumentReplaceCharRange(deleteBackTo, docChar, ''); } else { // normal delete performDocumentReplaceCharRange(docChar - 1, docChar, ''); } } } } else { performDocumentReplaceSelection(''); } } } // if the list has been removed, it is necessary to renumber // starting from the *next* line because the list may have been // separated. If it returns null, it means that the list was not cut, try // from the current one. const line = caretLine(); if (line !== -1 && renumberList(line + 1) == null) { renumberList(line); } }; const isWordChar = (c) => padutils.wordCharRegex.test(c); editorInfo.ace_isWordChar = isWordChar; const handleKeyEvent = (evt) => { if (!isEditable) return; const type = evt.type; const charCode = evt.charCode; const keyCode = evt.keyCode; const which = evt.which; const altKey = evt.altKey; const shiftKey = evt.shiftKey; // dmesg("keyevent type: "+type+", which: "+which); // Don't take action based on modifier keys going up and down. // Modifier keys do not generate "keypress" events. // 224 is the command-key under Mac Firefox. // 91 is the Windows key in IE; it is ASCII for open-bracket but isn't the keycode for that key // 20 is capslock in IE. const isModKey = ((!charCode) && ((type === 'keyup') || (type === 'keydown')) && ( keyCode === 16 || keyCode === 17 || keyCode === 18 || keyCode === 20 || keyCode === 224 || keyCode === 91 )); if (isModKey) return; // If the key is a keypress and the browser is opera and the key is enter, // do nothign at all as this fires twice. if (keyCode === 13 && browser.opera && (type === 'keypress')) { // This stops double enters in Opera but double Tabs still show on single // tab keypress, adding keyCode == 9 to this doesn't help as the event is fired twice return; } let specialHandled = false; const isTypeForSpecialKey = ((browser.safari || browser.chrome || browser.firefox) ? (type === 'keydown') : (type === 'keypress')); const isTypeForCmdKey = ((browser.safari || browser.chrome || browser.firefox) ? (type === 'keydown') : (type === 'keypress')); let stopped = false; inCallStackIfNecessary('handleKeyEvent', function () { if (type === 'keypress' || (isTypeForSpecialKey && keyCode === 13 /* return*/)) { // in IE, special keys don't send keypress, the keydown does the action if (!outsideKeyPress(evt)) { evt.preventDefault(); stopped = true; } } else if (evt.key === 'Dead') { // If it's a dead key we don't want to do any Etherpad behavior. stopped = true; return true; } else if (type === 'keydown') { outsideKeyDown(evt); } if (!stopped) { const specialHandledInHook = hooks.callAll('aceKeyEvent', { callstack: currentCallStack, editorInfo, rep, documentAttributeManager, evt, }); // if any hook returned true, set specialHandled with true if (specialHandledInHook) { specialHandled = specialHandledInHook.indexOf(true) !== -1; } const padShortcutEnabled = parent.parent.clientVars.padShortcutEnabled; if ( (!specialHandled) && altKey && isTypeForSpecialKey && keyCode === 120 && padShortcutEnabled.altF9 ) { // Alt F9 focuses on the File Menu and/or editbar. // Note that while most editors use Alt F10 this is not desirable // As ubuntu cannot use Alt F10.... // Focus on the editbar. // -- TODO: Move Focus back to previous state (we know it so we can use it) const firstEditbarElement = parent.parent.$('#editbar') .children('ul').first().children().first() .children().first().children().first(); $(this).blur(); firstEditbarElement.focus(); evt.preventDefault(); } if ( (!specialHandled) && altKey && keyCode === 67 && type === 'keydown' && padShortcutEnabled.altC ) { // Alt c focuses on the Chat window $(this).blur(); parent.parent.chat.show(); parent.parent.$('#chatinput').focus(); evt.preventDefault(); } if ( (!specialHandled) && evt.ctrlKey && shiftKey && keyCode === 50 && type === 'keydown' && padShortcutEnabled.cmdShift2 ) { // Control-Shift-2 shows a gritter popup showing a line author const lineNumber = rep.selEnd[0]; const alineAttrs = rep.alines[lineNumber]; const apool = rep.apool; // TODO: support selection ranges // TODO: Still work when authorship colors have been cleared // TODO: i18n // TODO: There appears to be a race condition or so. const authors = []; let author = null; if (alineAttrs) { const opIter = Changeset.opIterator(alineAttrs); while (opIter.hasNext()) { const op = opIter.next(); const authorId = Changeset.opAttributeValue(op, 'author', apool); // Only push unique authors and ones with values if (authors.indexOf(authorId) === -1 && authorId !== '') { authors.push(authorId); } } } let authorString; const authorNames = []; if (authors.length === 0) { authorString = 'No author information is available'; } else { // Known authors info, both current and historical const padAuthors = parent.parent.pad.userList(); let authorObj = {}; authors.forEach((authorId) => { padAuthors.forEach((padAuthor) => { // If the person doing the lookup is the author.. if (padAuthor.userId === authorId) { if (parent.parent.clientVars.userId === authorId) { authorObj = { name: 'Me', }; } else { authorObj = padAuthor; } } }); if (!authorObj) { author = 'Unknown'; return; } author = authorObj.name; if (!author) author = 'Unknown'; authorNames.push(author); }); } if (authors.length === 1) { authorString = `The author of this line is ${authorNames[0]}`; } if (authors.length > 1) { authorString = `The authors of this line are ${authorNames.join(' & ')}`; } parent.parent.$.gritter.add({ // (string | mandatory) the heading of the notification title: 'Line Authors', // (string | mandatory) the text inside the notification text: authorString, // (bool | optional) if you want it to fade out on its own or just sit there sticky: false, // (int | optional) the time you want it to be alive for before fading out time: '4000', }); } if ((!specialHandled) && isTypeForSpecialKey && keyCode === 8 && padShortcutEnabled.delete ) { // "delete" key; in mozilla, if we're at the beginning of a line, normalize now, // or else deleting a blank line can take two delete presses. // -- // we do deletes completely customly now: // - allows consistent (and better) meta-delete behavior // - normalizing and then allowing default behavior confused IE // - probably eliminates a few minor quirks fastIncorp(3); evt.preventDefault(); doDeleteKey(evt); specialHandled = true; } if ((!specialHandled) && isTypeForSpecialKey && keyCode === 13 && padShortcutEnabled.return ) { // return key, handle specially; // note that in mozilla we need to do an incorporation for proper return behavior anyway. fastIncorp(4); evt.preventDefault(); doReturnKey(); // scrollSelectionIntoView(); scheduler.setTimeout(() => { outerWin.scrollBy(-100, 0); }, 0); specialHandled = true; } if ((!specialHandled) && isTypeForSpecialKey && keyCode === 27 && padShortcutEnabled.esc ) { // prevent esc key; // in mozilla versions 14-19 avoid reconnecting pad. fastIncorp(4); evt.preventDefault(); specialHandled = true; // close all gritters when the user hits escape key parent.parent.$.gritter.removeAll(); } if ( (!specialHandled) && /* Do a saved revision on ctrl S */ isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 's' && (evt.metaKey || evt.ctrlKey) && !evt.altKey && padShortcutEnabled.cmdS ) { evt.preventDefault(); const originalBackground = parent.parent.$('#revisionlink').css('background'); parent.parent.$('#revisionlink').css({background: 'lightyellow'}); scheduler.setTimeout(() => { parent.parent.$('#revisionlink').css({background: originalBackground}); }, 1000); /* The parent.parent part of this is BAD and I feel bad.. It may break something */ parent.parent.pad.collabClient.sendMessage({type: 'SAVE_REVISION'}); specialHandled = true; } if ((!specialHandled) && // tab isTypeForSpecialKey && keyCode === 9 && !(evt.metaKey || evt.ctrlKey) && padShortcutEnabled.tab) { fastIncorp(5); evt.preventDefault(); doTabKey(evt.shiftKey); // scrollSelectionIntoView(); specialHandled = true; } if ((!specialHandled) && // cmd-Z (undo) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'z' && (evt.metaKey || evt.ctrlKey) && !evt.altKey && padShortcutEnabled.cmdZ ) { fastIncorp(6); evt.preventDefault(); if (evt.shiftKey) { doUndoRedo('redo'); } else { doUndoRedo('undo'); } specialHandled = true; } if ((!specialHandled) && // cmd-Y (redo) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'y' && (evt.metaKey || evt.ctrlKey) && padShortcutEnabled.cmdY ) { fastIncorp(10); evt.preventDefault(); doUndoRedo('redo'); specialHandled = true; } if ((!specialHandled) && // cmd-B (bold) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'b' && (evt.metaKey || evt.ctrlKey) && padShortcutEnabled.cmdB) { fastIncorp(13); evt.preventDefault(); toggleAttributeOnSelection('bold'); specialHandled = true; } if ((!specialHandled) && // cmd-I (italic) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'i' && (evt.metaKey || evt.ctrlKey) && padShortcutEnabled.cmdI ) { fastIncorp(14); evt.preventDefault(); toggleAttributeOnSelection('italic'); specialHandled = true; } if ((!specialHandled) && isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'u' && (evt.metaKey || evt.ctrlKey) && padShortcutEnabled.cmdU ) { // cmd-U (underline) fastIncorp(15); evt.preventDefault(); toggleAttributeOnSelection('underline'); specialHandled = true; } if ((!specialHandled) && // cmd-5 (strikethrough) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === '5' && (evt.metaKey || evt.ctrlKey) && evt.altKey !== true && padShortcutEnabled.cmd5 ) { fastIncorp(13); evt.preventDefault(); toggleAttributeOnSelection('strikethrough'); specialHandled = true; } if ((!specialHandled) && // cmd-shift-L (unorderedlist) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'l' && (evt.metaKey || evt.ctrlKey) && evt.shiftKey && padShortcutEnabled.cmdShiftL ) { fastIncorp(9); evt.preventDefault(); doInsertUnorderedList(); specialHandled = true; } if ((!specialHandled) && // cmd-shift-N and cmd-shift-1 (orderedlist) isTypeForCmdKey && ( (String.fromCharCode(which).toLowerCase() === 'n' && padShortcutEnabled.cmdShiftN) || (String.fromCharCode(which) === '1' && padShortcutEnabled.cmdShift1) ) && (evt.metaKey || evt.ctrlKey) && evt.shiftKey ) { fastIncorp(9); evt.preventDefault(); doInsertOrderedList(); specialHandled = true; } if ((!specialHandled) && // cmd-shift-C (clearauthorship) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'c' && (evt.metaKey || evt.ctrlKey) && evt.shiftKey && padShortcutEnabled.cmdShiftC ) { fastIncorp(9); evt.preventDefault(); CMDS.clearauthorship(); } if ((!specialHandled) && // cmd-H (backspace) isTypeForCmdKey && String.fromCharCode(which).toLowerCase() === 'h' && (evt.ctrlKey) && padShortcutEnabled.cmdH ) { fastIncorp(20); evt.preventDefault(); doDeleteKey(); specialHandled = true; } if ((evt.which === 36 && evt.ctrlKey === true) && // Control Home send to Y = 0 padShortcutEnabled.ctrlHome) { scroll.setScrollY(0); } if ((evt.which === 33 || evt.which === 34) && type === 'keydown' && !evt.ctrlKey) { // This is required, browsers will try to do normal default behavior on // page up / down and the default behavior SUCKS evt.preventDefault(); const oldVisibleLineRange = scroll.getVisibleLineRange(rep); let topOffset = rep.selStart[0] - oldVisibleLineRange[0]; if (topOffset < 0) { topOffset = 0; } const isPageDown = evt.which === 34; const isPageUp = evt.which === 33; scheduler.setTimeout(() => { // the visible lines IE 1,10 const newVisibleLineRange = scroll.getVisibleLineRange(rep); // total count of lines in pad IE 10 const linesCount = rep.lines.length(); // How many lines are in the viewport right now? const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0]; if (isPageUp && padShortcutEnabled.pageUp) { // move to the bottom line +1 in the viewport (essentially skipping over a page) rep.selEnd[0] -= numberOfLinesInViewport; // move to the bottom line +1 in the viewport (essentially skipping over a page) rep.selStart[0] -= numberOfLinesInViewport; } // if we hit page down if (isPageDown && padShortcutEnabled.pageDown) { // If the new viewpoint position is actually further than where we are right now if (rep.selEnd[0] >= oldVisibleLineRange[0]) { // dont go further in the page down than what's visible IE go from 0 to 50 // if 50 is visible on screen but dont go below that else we miss content rep.selStart[0] = oldVisibleLineRange[1] - 1; // dont go further in the page down than what's visible IE go from 0 to 50 // if 50 is visible on screen but dont go below that else we miss content rep.selEnd[0] = oldVisibleLineRange[1] - 1; } } // ensure min and max if (rep.selEnd[0] < 0) { rep.selEnd[0] = 0; } if (rep.selStart[0] < 0) { rep.selStart[0] = 0; } if (rep.selEnd[0] >= linesCount) { rep.selEnd[0] = linesCount - 1; } updateBrowserSelectionFromRep(); // get the current caret selection, can't use rep. here because that only gives // us the start position not the current const myselection = document.getSelection(); // get the carets selection offset in px IE 214 let caretOffsetTop = myselection.focusNode.parentNode.offsetTop || myselection.focusNode.offsetTop; // sometimes the first selection is -1 which causes problems // (Especially with ep_page_view) // so use focusNode.offsetTop value. if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop; // set the scrollY offset of the viewport on the document scroll.setScrollY(caretOffsetTop); }, 200); } // scroll to viewport when user presses arrow keys and caret is out of the viewport if ((evt.which === 37 || evt.which === 38 || evt.which === 39 || evt.which === 40)) { // we use arrowKeyWasReleased to avoid triggering the animation when a key // is continuously pressed // this makes the scroll smooth if (!continuouslyPressingArrowKey(type)) { // the caret position is not synchronized with the rep. // For example, when an user presses arrow // We use getSelection() instead of rep to get the caret position. // This avoids errors like when down to scroll the pad without releasing the key. // When the key is released the rep is not // synchronized, so we don't get the right node where caret is. const selection = getSelection(); if (selection) { const arrowUp = evt.which === 38; const innerHeight = getInnerHeight(); scroll.scrollWhenPressArrowKeys(arrowUp, rep, innerHeight); } } } } if (type === 'keydown') { idleWorkTimer.atLeast(500); } else if (type === 'keypress') { // OPINION ASKED. What's going on here? :D if (!specialHandled) { idleWorkTimer.atMost(0); } else { idleWorkTimer.atLeast(500); } } else if (type === 'keyup') { const wait = 0; idleWorkTimer.atLeast(wait); idleWorkTimer.atMost(wait); } // Is part of multi-keystroke international character on Firefox Mac const isFirefoxHalfCharacter = (browser.firefox && evt.altKey && charCode === 0 && keyCode === 0); // Is part of multi-keystroke international character on Safari Mac const isSafariHalfCharacter = (browser.safari && evt.altKey && keyCode === 229); if (thisKeyDoesntTriggerNormalize || isFirefoxHalfCharacter || isSafariHalfCharacter) { idleWorkTimer.atLeast(3000); // give user time to type // if this is a keydown, e.g., the keyup shouldn't trigger a normalize thisKeyDoesntTriggerNormalize = true; } if ((!specialHandled) && (!thisKeyDoesntTriggerNormalize) && (!inInternationalComposition)) { if (type !== 'keyup') { observeChangesAroundSelection(); } } if (type === 'keyup') { thisKeyDoesntTriggerNormalize = false; } }); }; let thisKeyDoesntTriggerNormalize = false; let arrowKeyWasReleased = true; const continuouslyPressingArrowKey = (type) => { let firstTimeKeyIsContinuouslyPressed = false; if (type === 'keyup') { arrowKeyWasReleased = true; } else if (type === 'keydown' && arrowKeyWasReleased) { firstTimeKeyIsContinuouslyPressed = true; arrowKeyWasReleased = false; } return !firstTimeKeyIsContinuouslyPressed; }; const doUndoRedo = (which) => { // precond: normalized DOM if (undoModule.enabled) { let whichMethod; if (which === 'undo') whichMethod = 'performUndo'; if (which === 'redo') whichMethod = 'performRedo'; if (whichMethod) { const oldEventType = currentCallStack.editEvent.eventType; currentCallStack.startNewEvent(which); undoModule[whichMethod]((backset, selectionInfo) => { if (backset) { performDocumentApplyChangeset(backset); } if (selectionInfo) { performSelectionChange( lineAndColumnFromChar( selectionInfo.selStart ), lineAndColumnFromChar(selectionInfo.selEnd), selectionInfo.selFocusAtStart ); } const oldEvent = currentCallStack.startNewEvent(oldEventType, true); return oldEvent; }); } } }; editorInfo.ace_doUndoRedo = doUndoRedo; const setSelection = (selection) => { const copyPoint = (pt) => ({ node: pt.node, index: pt.index, maxIndex: pt.maxIndex, }); let isCollapsed; const pointToRangeBound = (pt) => { const p = copyPoint(pt); // Make sure Firefox cursor is deep enough; fixes cursor jumping when at top level, // and also problem where cut/copy of a whole line selected with fake arrow-keys // copies the next line too. if (isCollapsed) { const diveDeep = () => { while (p.node.childNodes.length > 0) { // && (p.node == root || p.node.parentNode == root)) { if (p.index === 0) { p.node = p.node.firstChild; p.maxIndex = nodeMaxIndex(p.node); } else if (p.index === p.maxIndex) { p.node = p.node.lastChild; p.maxIndex = nodeMaxIndex(p.node); p.index = p.maxIndex; } else { break; } } }; // now fix problem where cursor at end of text node at end of span-like element // with background doesn't seem to show up... if (isNodeText(p.node) && p.index === p.maxIndex) { let n = p.node; while ((!n.nextSibling) && (n !== root) && (n.parentNode !== root)) { n = n.parentNode; } if ( n.nextSibling && (!((typeof n.nextSibling.tagName) === 'string' && n.nextSibling.tagName.toLowerCase() === 'br')) && (n !== p.node) && (n !== root) && (n.parentNode !== root) ) { // found a parent, go to next node and dive in p.node = n.nextSibling; p.maxIndex = nodeMaxIndex(p.node); p.index = 0; diveDeep(); } } // try to make sure insertion point is styled; // also fixes other FF problems if (!isNodeText(p.node)) { diveDeep(); } } if (isNodeText(p.node)) { return { container: p.node, offset: p.index, }; } else { // p.index in {0,1} return { container: p.node.parentNode, offset: childIndex(p.node) + p.index, }; } }; const browserSelection = window.getSelection(); if (browserSelection) { browserSelection.removeAllRanges(); if (selection) { isCollapsed = ( selection.startPoint.node === selection.endPoint.node && selection.startPoint.index === selection.endPoint.index ); const start = pointToRangeBound(selection.startPoint); const end = pointToRangeBound(selection.endPoint); if ( (!isCollapsed) && selection.focusAtStart && browserSelection.collapse && browserSelection.extend ) { // can handle "backwards"-oriented selection, shift-arrow-keys move start // of selection browserSelection.collapse(end.container, end.offset); browserSelection.extend(start.container, start.offset); } else { const range = doc.createRange(); range.setStart(start.container, start.offset); range.setEnd(end.container, end.offset); browserSelection.removeAllRanges(); browserSelection.addRange(range); } } } }; const updateBrowserSelectionFromRep = () => { // requires normalized DOM! const selStart = rep.selStart; const selEnd = rep.selEnd; if (!(selStart && selEnd)) { setSelection(null); return; } const selection = {}; const ss = [selStart[0], selStart[1]]; selection.startPoint = getPointForLineAndChar(ss); const se = [selEnd[0], selEnd[1]]; selection.endPoint = getPointForLineAndChar(se); selection.focusAtStart = !!rep.selFocusAtStart; setSelection(selection); }; editorInfo.ace_updateBrowserSelectionFromRep = updateBrowserSelectionFromRep; editorInfo.ace_focus = focus; editorInfo.ace_importText = importText; editorInfo.ace_importAText = importAText; editorInfo.ace_exportText = exportText; editorInfo.ace_editorChangedSize = editorChangedSize; editorInfo.ace_setOnKeyPress = setOnKeyPress; editorInfo.ace_setOnKeyDown = setOnKeyDown; editorInfo.ace_setNotifyDirty = setNotifyDirty; editorInfo.ace_dispose = dispose; editorInfo.ace_getFormattedCode = getFormattedCode; editorInfo.ace_setEditable = setEditable; editorInfo.ace_execCommand = execCommand; editorInfo.ace_replaceRange = replaceRange; editorInfo.ace_getAuthorInfos = getAuthorInfos; editorInfo.ace_performDocumentReplaceRange = performDocumentReplaceRange; editorInfo.ace_performDocumentReplaceCharRange = performDocumentReplaceCharRange; editorInfo.ace_setSelection = setSelection; const nodeMaxIndex = (nd) => { if (isNodeText(nd)) return nd.nodeValue.length; else return 1; }; const getSelection = () => { // returns null, or a structure containing startPoint and endPoint, // each of which has node (a magicdom node), index, and maxIndex. If the node // is a text node, maxIndex is the length of the text; else maxIndex is 1. // index is between 0 and maxIndex, inclusive. const browserSelection = window.getSelection(); if (!browserSelection || browserSelection.type === 'None' || browserSelection.rangeCount === 0) { return null; } const range = browserSelection.getRangeAt(0); const isInBody = (n) => { while (n && !(n.tagName && n.tagName.toLowerCase() === 'body')) { n = n.parentNode; } return !!n; }; const pointFromRangeBound = (container, offset) => { if (!isInBody(container)) { // command-click in Firefox selects whole document, HEAD and BODY! return { node: root, index: 0, maxIndex: 1, }; } const n = container; const childCount = n.childNodes.length; if (isNodeText(n)) { return { node: n, index: offset, maxIndex: n.nodeValue.length, }; } else if (childCount === 0) { return { node: n, index: 0, maxIndex: 1, }; // treat point between two nodes as BEFORE the second (rather than after the first) // if possible; this way point at end of a line block-element is treated as // at beginning of next line } else if (offset === childCount) { const nd = n.childNodes.item(childCount - 1); const max = nodeMaxIndex(nd); return { node: nd, index: max, maxIndex: max, }; } else { const nd = n.childNodes.item(offset); const max = nodeMaxIndex(nd); return { node: nd, index: 0, maxIndex: max, }; } }; const selection = { startPoint: pointFromRangeBound(range.startContainer, range.startOffset), endPoint: pointFromRangeBound(range.endContainer, range.endOffset), focusAtStart: (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) && browserSelection.anchorNode && browserSelection.anchorNode === range.endContainer && browserSelection.anchorOffset === range.endOffset, }; if (selection.startPoint.node.ownerDocument !== window.document) { return null; } return selection; }; const childIndex = (n) => { let idx = 0; while (n.previousSibling) { idx++; n = n.previousSibling; } return idx; }; const fixView = () => { // calling this method repeatedly should be fast if (getInnerWidth() === 0 || getInnerHeight() === 0) { return; } enforceEditability(); $(sideDiv).addClass('sidedivdelayed'); }; const _teardownActions = []; const teardown = () => _teardownActions.forEach((a) => a()); let inInternationalComposition = false; const handleCompositionEvent = (evt) => { // international input events, fired in FF3, at least; allow e.g. Japanese input if (evt.type === 'compositionstart') { inInternationalComposition = true; } else if (evt.type === 'compositionend') { inInternationalComposition = false; } }; editorInfo.ace_getInInternationalComposition = () => inInternationalComposition; const bindTheEventHandlers = () => { $(document).on('keydown', handleKeyEvent); $(document).on('keypress', handleKeyEvent); $(document).on('keyup', handleKeyEvent); $(document).on('click', handleClick); // dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer $(outerWin.document).on('click', hideEditBarDropdowns); // Disabled: https://github.com/ether/etherpad-lite/issues/2546 // Will break OL re-numbering: https://github.com/ether/etherpad-lite/pull/2533 // $(document).on("cut", handleCut); // If non-nullish, pasting on a link should be suppressed. let suppressPasteOnLink = null; $(root).on('auxclick', (e) => { if (e.originalEvent.button === 1 && (e.target.a || e.target.localName === 'a')) { // The user middle-clicked on a link. Usually users do this to open a link in a new tab, but // in X11 (Linux) this will instead paste the contents of the primary selection at the mouse // cursor. Users almost certainly do not want to paste when middle-clicking on a link, so // tell the 'paste' event handler to suppress the paste. This is done by starting a // short-lived timer that suppresses paste (when the target is a link) until either the // paste event arrives or the timer fires. // // Why it is implemented this way: // * Users want to be able to paste on a link via Ctrl-V, the Edit menu, or the context // menu (https://github.com/ether/etherpad-lite/issues/2775) so we cannot simply // suppress all paste actions when the target is a link. // * Non-X11 systems do not paste when the user middle-clicks, so the paste suppression // must be self-resetting. // * On non-X11 systems, middle click should continue to open the link in a new tab. // Suppressing the middle click here in the 'auxclick' handler (via e.preventDefault()) // would break that behavior. suppressPasteOnLink = scheduler.setTimeout(() => { suppressPasteOnLink = null; }, 0); } }); $(root).on('paste', (e) => { if (suppressPasteOnLink != null && (e.target.a || e.target.localName === 'a')) { scheduler.clearTimeout(suppressPasteOnLink); suppressPasteOnLink = null; e.preventDefault(); return; } // Call paste hook hooks.callAll('acePaste', { editorInfo, rep, documentAttributeManager, e, }); }); // We reference document here, this is because if we don't this will expose a bug // in Google Chrome. This bug will cause the last character on the last line to // not fire an event when dropped into.. $(document).on('drop', (e) => { if (e.target.a || e.target.localName === 'a') { e.preventDefault(); } // Bug fix: when user drags some content and drop it far from its origin, we // need to merge the changes into a single changeset. So mark origin with