mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-04-23 00:46:16 -04:00

Safari takes a while to initialize `document.styleSheets`, which results in a race condition when loading the pad. Avoid the race condition by accessing the CSSStyleSheet objects directly from the HTMLStyleElement DOM objects.
3919 lines
130 KiB
JavaScript
3919 lines
130 KiB
JavaScript
'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('./vendors/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(editorInfo, cssManagers) {
|
|
const makeChangesetTracker = require('./changesettracker').makeChangesetTracker;
|
|
const colorutils = require('./colorutils').colorutils;
|
|
const makeContentCollector = require('./contentcollector').makeContentCollector;
|
|
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 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 = '<div id="sidedivinner" class="sidedivinner"><div><span class="line-number">1';
|
|
const htmlClose = '</span></div></div>';
|
|
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
|
|
|
|
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]: <CCC end[1] CCC>-------\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 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) => {
|
|
const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author));
|
|
|
|
const authorStyleSet = hooks.callAll('aceSetAuthorStyle', {
|
|
dynamicCSS: cssManagers.inner,
|
|
outerDynamicCSS: cssManagers.outer,
|
|
parentDynamicCSS: cssManagers.parent,
|
|
info,
|
|
author,
|
|
authorSelector,
|
|
});
|
|
|
|
// Prevent default behaviour if any hook says so
|
|
if (authorStyleSet.some((it) => it)) {
|
|
return;
|
|
}
|
|
|
|
if (!info) {
|
|
cssManagers.inner.removeSelectorStyle(authorSelector);
|
|
cssManagers.parent.removeSelectorStyle(authorSelector);
|
|
} else if (info.bgcolor) {
|
|
let bgcolor = info.bgcolor;
|
|
if ((typeof info.fade) === 'number') {
|
|
bgcolor = fadeColor(bgcolor, info.fade);
|
|
}
|
|
|
|
const authorStyle = cssManagers.inner.selectorStyle(authorSelector);
|
|
const parentAuthorStyle = cssManagers.parent.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 (!author) return; // author ID not set for some reason
|
|
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 `<div class="syntax"><div>${buf.join('</div>\n<div>')}</div></div>`;
|
|
};
|
|
|
|
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();
|
|
},
|
|
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 = '<div><!-- --></div>';
|
|
}
|
|
|
|
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 * 2
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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 <style>,
|
|
// in order to make content be observed by incorporateUserChanges() (see
|
|
// observeSuspiciousNodes() for more info)
|
|
const selection = getSelection();
|
|
if (selection) {
|
|
const firstLineSelected = topLevel(selection.startPoint.node);
|
|
const lastLineSelected = topLevel(selection.endPoint.node);
|
|
|
|
const lineBeforeSelection = firstLineSelected.previousSibling;
|
|
const lineAfterSelection = lastLineSelected.nextSibling;
|
|
|
|
const neighbor = lineBeforeSelection || lineAfterSelection;
|
|
neighbor.appendChild(document.createElement('style'));
|
|
}
|
|
|
|
// Call drop hook
|
|
hooks.callAll('aceDrop', {
|
|
editorInfo,
|
|
rep,
|
|
documentAttributeManager,
|
|
e,
|
|
});
|
|
});
|
|
|
|
$(document.documentElement).on('compositionstart', handleCompositionEvent);
|
|
$(document.documentElement).on('compositionend', handleCompositionEvent);
|
|
};
|
|
|
|
const topLevel = (n) => {
|
|
if ((!n) || n === root) return null;
|
|
while (n.parentNode !== root) {
|
|
n = n.parentNode;
|
|
}
|
|
return n;
|
|
};
|
|
|
|
const getSelectionPointX = (point) => {
|
|
// doesn't work in wrap-mode
|
|
const node = point.node;
|
|
const index = point.index;
|
|
const leftOf = (n) => n.offsetLeft;
|
|
const rightOf = (n) => n.offsetLeft + n.offsetWidth;
|
|
|
|
if (!isNodeText(node)) {
|
|
if (index === 0) return leftOf(node);
|
|
else return rightOf(node);
|
|
} else {
|
|
// we can get bounds of element nodes, so look for those.
|
|
// allow consecutive text nodes for robustness.
|
|
let charsToLeft = index;
|
|
let charsToRight = node.nodeValue.length - index;
|
|
let n;
|
|
for (n = node.previousSibling; n &&
|
|
isNodeText(n); n = n.previousSibling) {
|
|
charsToLeft += n.nodeValue;
|
|
}
|
|
const leftEdge = (n ? rightOf(n) : leftOf(node.parentNode));
|
|
for (n = node.nextSibling; n && isNodeText(n); n = n.nextSibling) charsToRight += n.nodeValue;
|
|
const rightEdge = (n ? leftOf(n) : rightOf(node.parentNode));
|
|
const frac = (charsToLeft / (charsToLeft + charsToRight));
|
|
const pixLoc = leftEdge + frac * (rightEdge - leftEdge);
|
|
return Math.round(pixLoc);
|
|
}
|
|
};
|
|
|
|
const getInnerHeight = () => {
|
|
const win = outerWin;
|
|
const odoc = win.document;
|
|
let h;
|
|
if (browser.opera) h = win.innerHeight;
|
|
else h = odoc.documentElement.clientHeight;
|
|
if (h) return h;
|
|
|
|
// deal with case where iframe is hidden, hope that
|
|
// style.height of iframe container is set in px
|
|
return Number(editorInfo.frame.parentNode.style.height.replace(/[^0-9]/g, '') || 0);
|
|
};
|
|
|
|
const getInnerWidth = () => {
|
|
const win = outerWin;
|
|
const odoc = win.document;
|
|
return odoc.documentElement.clientWidth;
|
|
};
|
|
|
|
const scrollXHorizontallyIntoView = (pixelX) => {
|
|
const win = outerWin;
|
|
const distInsideLeft = pixelX - win.scrollX;
|
|
const distInsideRight = win.scrollX + getInnerWidth() - pixelX;
|
|
if (distInsideLeft < 0) {
|
|
win.scrollBy(distInsideLeft, 0);
|
|
} else if (distInsideRight < 0) {
|
|
win.scrollBy(-distInsideRight + 1, 0);
|
|
}
|
|
};
|
|
|
|
const scrollSelectionIntoView = () => {
|
|
if (!rep.selStart) return;
|
|
fixView();
|
|
const innerHeight = getInnerHeight();
|
|
scroll.scrollNodeVerticallyIntoView(rep, innerHeight);
|
|
if (!doesWrap) {
|
|
const browserSelection = getSelection();
|
|
if (browserSelection) {
|
|
const focusPoint = (
|
|
browserSelection.focusAtStart ? browserSelection.startPoint : browserSelection.endPoint
|
|
);
|
|
const selectionPointX = getSelectionPointX(focusPoint);
|
|
scrollXHorizontallyIntoView(selectionPointX);
|
|
fixView();
|
|
}
|
|
}
|
|
};
|
|
|
|
const listAttributeName = 'list';
|
|
|
|
const getLineListType = (lineNum) => documentAttributeManager
|
|
.getAttributeOnLine(lineNum, listAttributeName);
|
|
editorInfo.ace_getLineListType = getLineListType;
|
|
|
|
|
|
const doInsertList = (type) => {
|
|
if (!(rep.selStart && rep.selEnd)) {
|
|
return;
|
|
}
|
|
|
|
const firstLine = rep.selStart[0];
|
|
const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0));
|
|
|
|
let allLinesAreList = true;
|
|
for (let n = firstLine; n <= lastLine; n++) {
|
|
const listType = getLineListType(n);
|
|
if (!listType || listType.slice(0, type.length) !== type) {
|
|
allLinesAreList = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const mods = [];
|
|
for (let n = firstLine; n <= lastLine; n++) {
|
|
// var t = '';
|
|
let level = 0;
|
|
let togglingOn = true;
|
|
const listType = /([a-z]+)([0-9]+)/.exec(getLineListType(n));
|
|
|
|
// Used to outdent if ol is removed
|
|
if (allLinesAreList) {
|
|
togglingOn = false;
|
|
}
|
|
|
|
if (listType) {
|
|
// t = listType[1];
|
|
level = Number(listType[2]);
|
|
}
|
|
const t = getLineListType(n);
|
|
|
|
if (t === listType) togglingOn = false;
|
|
|
|
if (togglingOn) {
|
|
mods.push([n, allLinesAreList ? `indent${level}` : (t ? type + level : `${type}1`)]);
|
|
} else {
|
|
// scrap the entire indentation and list type
|
|
if (level === 1) { // if outdending but are the first item in the list then outdent
|
|
setLineListType(n, ''); // outdent
|
|
}
|
|
// else change to indented not bullet
|
|
if (level > 1) {
|
|
setLineListType(n, ''); // remove bullet
|
|
setLineListType(n, `indent${level}`); // in/outdent
|
|
}
|
|
}
|
|
}
|
|
|
|
mods.forEach((mod) => {
|
|
setLineListType(mod[0], mod[1]);
|
|
});
|
|
};
|
|
|
|
const doInsertUnorderedList = () => {
|
|
doInsertList('bullet');
|
|
};
|
|
const doInsertOrderedList = () => {
|
|
doInsertList('number');
|
|
};
|
|
editorInfo.ace_doInsertUnorderedList = doInsertUnorderedList;
|
|
editorInfo.ace_doInsertOrderedList = doInsertOrderedList;
|
|
|
|
|
|
// We apply the height of a line in the doc body, to the corresponding sidediv line number
|
|
const updateLineNumbers = () => {
|
|
if (!currentCallStack || !currentCallStack.domClean) return;
|
|
|
|
// Refs #4228, to avoid layout trashing, we need to first calculate all the heights,
|
|
// and then apply at once all new height to div elements
|
|
const lineOffsets = [];
|
|
|
|
// To place the line number on the same Z point as the first character of the first line
|
|
// we need to know the line height including the margins of the firstChild within the line
|
|
// This is somewhat computationally expensive as it looks at the first element within
|
|
// the line. Alternative, cheaper approaches are welcome.
|
|
// Original Issue: https://github.com/ether/etherpad-lite/issues/4527
|
|
const lineHeights = [];
|
|
|
|
// 24 is the default line height within Etherpad - There may be quirks here such as
|
|
// none text elements (images/embeds etc) where the line height will be greater
|
|
// but as it's non-text type the line-height/margins might not be present and it
|
|
// could be that this breaks a theme that has a different default line height..
|
|
// So instead of using an integer here we get the value from the Editor CSS.
|
|
const innerdocbody = document.querySelector('#innerdocbody');
|
|
const innerdocbodyStyles = getComputedStyle(innerdocbody);
|
|
const defaultLineHeight = parseInt(innerdocbodyStyles['line-height']);
|
|
|
|
let docLine = doc.body.firstChild;
|
|
let currentLine = 0;
|
|
let h = null;
|
|
|
|
// First loop to calculate the heights from doc body
|
|
while (docLine) {
|
|
if (docLine.nextSibling) {
|
|
if (currentLine === 0) {
|
|
// It's the first line. For line number alignment purposes, its
|
|
// height is taken to be the top offset of the next line. If we
|
|
// didn't do this special case, we would miss out on any top margin
|
|
// included on the first line. The default stylesheet doesn't add
|
|
// extra margins/padding, but plugins might.
|
|
h = docLine.nextSibling.offsetTop - parseInt(
|
|
window.getComputedStyle(doc.body)
|
|
.getPropertyValue('padding-top').split('px')[0]
|
|
);
|
|
} else {
|
|
h = docLine.nextSibling.offsetTop - docLine.offsetTop;
|
|
}
|
|
} else {
|
|
// last line
|
|
h = (docLine.clientHeight || docLine.offsetHeight);
|
|
}
|
|
lineOffsets.push(h);
|
|
|
|
if (docLine.clientHeight !== defaultLineHeight) {
|
|
// line is wrapped OR has a larger line height within so we will do additional
|
|
// computation to figure out the line-height of the first element and
|
|
// use that for displaying the side div line number inline with the first line
|
|
// of content -- This is used in ep_headings, ep_font_size etc. where the line
|
|
// height is increased.
|
|
const elementStyle = window.getComputedStyle(docLine.firstChild);
|
|
const lineHeight = parseInt(elementStyle.getPropertyValue('line-height'));
|
|
const marginBottom = parseInt(elementStyle.getPropertyValue('margin-bottom'));
|
|
lineHeights.push(lineHeight + marginBottom);
|
|
} else {
|
|
lineHeights.push(defaultLineHeight);
|
|
}
|
|
docLine = docLine.nextSibling;
|
|
currentLine++;
|
|
}
|
|
|
|
let newNumLines = rep.lines.length();
|
|
if (newNumLines < 1) newNumLines = 1;
|
|
let sidebarLine = sideDivInner.firstChild;
|
|
|
|
// Apply height to existing sidediv lines
|
|
currentLine = 0;
|
|
while (sidebarLine && currentLine <= lineNumbersShown) {
|
|
if (lineOffsets[currentLine] != null) {
|
|
sidebarLine.style.height = `${lineOffsets[currentLine]}px`;
|
|
sidebarLine.style.lineHeight = `${lineHeights[currentLine]}px`;
|
|
}
|
|
sidebarLine = sidebarLine.nextSibling;
|
|
currentLine++;
|
|
}
|
|
|
|
if (newNumLines !== lineNumbersShown) {
|
|
const container = sideDivInner;
|
|
const odoc = outerWin.document;
|
|
const fragment = odoc.createDocumentFragment();
|
|
|
|
// Create missing line and apply height
|
|
while (lineNumbersShown < newNumLines) {
|
|
lineNumbersShown++;
|
|
const div = odoc.createElement('DIV');
|
|
if (lineOffsets[currentLine]) {
|
|
div.style.height = `${lineOffsets[currentLine]}px`;
|
|
div.style.lineHeight = `${lineHeights[currentLine]}px`;
|
|
}
|
|
$(div).append($(`<span class='line-number'>${String(lineNumbersShown)}</span>`));
|
|
fragment.appendChild(div);
|
|
currentLine++;
|
|
}
|
|
container.appendChild(fragment);
|
|
|
|
// Remove extra lines
|
|
while (lineNumbersShown > newNumLines) {
|
|
container.removeChild(container.lastChild);
|
|
lineNumbersShown--;
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
// Init documentAttributeManager
|
|
documentAttributeManager = new AttributeManager(rep, performDocumentApplyChangeset);
|
|
|
|
editorInfo.ace_performDocumentApplyAttributesToRange =
|
|
(...args) => documentAttributeManager.setAttributesOnRange(...args);
|
|
|
|
this.init = async () => {
|
|
await $.ready;
|
|
doc = document; // defined as a var in scope outside
|
|
inCallStack('setup', () => {
|
|
const body = doc.getElementById('innerdocbody');
|
|
root = body; // defined as a var in scope outside
|
|
if (browser.firefox) $(root).addClass('mozilla');
|
|
if (browser.safari) $(root).addClass('safari');
|
|
root.classList.toggle('authorColors', true);
|
|
root.classList.toggle('doesWrap', doesWrap);
|
|
|
|
enforceEditability();
|
|
|
|
// set up dom and rep
|
|
while (root.firstChild) root.removeChild(root.firstChild);
|
|
const oneEntry = createDomLineEntry('');
|
|
doRepLineSplice(0, rep.lines.length(), [oneEntry]);
|
|
insertDomLines(null, [oneEntry.domInfo]);
|
|
rep.alines = Changeset.splitAttributionLines(
|
|
Changeset.makeAttribution('\n'), '\n');
|
|
|
|
bindTheEventHandlers();
|
|
});
|
|
|
|
hooks.callAll('aceInitialized', {
|
|
editorInfo,
|
|
rep,
|
|
documentAttributeManager,
|
|
});
|
|
};
|
|
}
|
|
|
|
exports.init = async (editorInfo, cssManagers) => {
|
|
const editor = new Ace2Inner(editorInfo, cssManagers);
|
|
await editor.init();
|
|
};
|