/* * A small rich-text editor for the story field, in place of TinyMCE. * * The textarea stays in the form and is what gets posted. The editor hides * it, shows a contenteditable box with the same content, and copies the * box back into the textarea after every change. Without JavaScript the * plain textarea is still there. * * The toolbar has the buttons that the TinyMCE setup had: bold, italic, * the four alignments, blockquote, outdent, indent, link, unlink, special * characters and the HTML source. The HTML it writes is the same as * TinyMCE wrote, for example
and * padding-left steps of 30px. The server cleans the story with HTML * Purifier in any case. * * The source code button swaps the editor for the plain textarea, so that * the HTML can be edited directly, and swaps it back when pressed again. * * Usage: XxxEditor.attach(textarea) returns an object with getContent(), * setContent(html) and focus(). */ var XxxEditor = (function () { "use strict"; var SVG_NS = "http://www.w3.org/2000/svg"; // Icons in a 16-unit box. "fill" paths are solid shapes; "stroke" paths // are lines, in their own box size. var ICONS = { bold: { fill: ["M3.5 2h5.2c2.3 0 3.7 1.2 3.7 3.05 0 1.2-.6 2.1-1.6 2.6 1.3.4 2.2 1.5 2.2 3 0 2.1-1.6 3.35-4 3.35H3.5z" + "M6.2 4.3v2.6h2.2c.9 0 1.4-.5 1.4-1.3s-.5-1.3-1.4-1.3z" + "M6.2 9.1v2.7h2.5c1 0 1.6-.5 1.6-1.35s-.6-1.35-1.6-1.35z"] }, italic: { fill: ["M6.5 2h6v1.8h-2.1L8.1 12.2h2.1V14h-6v-1.8h2.1l2.3-8.4H6.5z"] }, alignleft: { fill: ["M1 2h14v2H1zM1 6h9v2H1zM1 10h14v2H1zM1 14h9v2H1z"] }, aligncenter: { fill: ["M1 2h14v2H1zM3.5 6h9v2h-9zM1 10h14v2H1zM3.5 14h9v2h-9z"] }, alignright: { fill: ["M1 2h14v2H1zM6 6h9v2H6zM1 10h14v2H1zM6 14h9v2H6z"] }, alignjustify: { fill: ["M1 2h14v2H1zM1 6h14v2H1zM1 10h14v2H1zM1 14h14v2H1z"] }, blockquote: { fill: ["M7.5 10.5a3 3 0 1 1-6 0C1.5 6.8 3.4 4.3 7 3.6v1.8C5.3 5.9 4.4 6.9 4.2 7.6A3 3 0 0 1 7.5 10.5z" + "M14.5 10.5a3 3 0 1 1-6 0C8.5 6.8 10.4 4.3 14 3.6v1.8c-1.7.5-2.6 1.5-2.8 2.2a3 3 0 0 1 3.3 2.9z"] }, outdent: { fill: ["M1 2h14v2H1zM1 6h8v2H1zM1 10h8v2H1zM1 14h14v2H1zM15 5.5v5L11 8z"] }, indent: { fill: ["M1 2h14v2H1zM7 6h8v2H7zM7 10h8v2H7zM1 14h14v2H1zM1 5.5v5L5 8z"] }, link: { box: 24, stroke: ["M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1-1"] }, unlink: { box: 24, stroke: ["M16.4 13.3L18.9 10.7A4 4 0 0 0 13.3 5.1L10.7 7.6", "M13.3 16.4L10.7 18.9A4 4 0 0 1 5.1 13.3L7.6 10.7"] }, charmap: { box: 16, width: 1.7, stroke: ["M2 14.2h3.9v-1.7a5 5 0 1 1 4.2 0v1.7H14"] }, code: { box: 16, width: 1.6, stroke: ["M5.5 5L2.5 8l3 3", "M10.5 5l3 3-3 3"] } }; // Toolbar groups, in the same order as the TinyMCE toolbar var TOOLBAR = [ [{ command: "bold", title: "Bold", key: "b" }, { command: "italic", title: "Italic", key: "i" }], [{ command: "alignleft", title: "Align left" }, { command: "aligncenter", title: "Align center" }, { command: "alignright", title: "Align right" }, { command: "alignjustify", title: "Justify" }], [{ command: "blockquote", title: "Blockquote" }], [{ command: "outdent", title: "Decrease indent" }, { command: "indent", title: "Increase indent" }], [{ command: "link", title: "Insert/edit link", key: "k" }, { command: "unlink", title: "Remove link" }], [{ command: "charmap", title: "Special character" }, { command: "code", title: "Source code" }] ]; var ALIGN = { alignleft: "left", aligncenter: "center", alignright: "right", alignjustify: "justify" }; var INDENT_STEP = 30; // pixels, as TinyMCE used // The special characters, as in the TinyMCE charmap plugin var CHARMAP = [ [160, "no-break space"], [173, "soft hyphen"], [34, "quotation mark"], [162, "cent sign"], [8364, "euro sign"], [163, "pound sign"], [165, "yen sign"], [169, "copyright sign"], [174, "registered sign"], [8482, "trade mark sign"], [8240, "per mille sign"], [181, "micro sign"], [183, "middle dot"], [8226, "bullet"], [8230, "three dot leader"], [8242, "minutes / feet"], [8243, "seconds / inches"], [167, "section sign"], [182, "paragraph sign"], [223, "sharp s / ess-zed"], [8249, "single left-pointing angle quotation mark"], [8250, "single right-pointing angle quotation mark"], [171, "left pointing guillemet"], [187, "right pointing guillemet"], [8216, "left single quotation mark"], [8217, "right single quotation mark"], [8220, "left double quotation mark"], [8221, "right double quotation mark"], [8218, "single low-9 quotation mark"], [8222, "double low-9 quotation mark"], [60, "less-than sign"], [62, "greater-than sign"], [8804, "less-than or equal to"], [8805, "greater-than or equal to"], [8211, "en dash"], [8212, "em dash"], [175, "macron"], [8254, "overline"], [164, "currency sign"], [166, "broken bar"], [168, "diaeresis"], [161, "inverted exclamation mark"], [191, "turned question mark"], [710, "circumflex accent"], [732, "small tilde"], [176, "degree sign"], [8722, "minus sign"], [177, "plus-minus sign"], [247, "division sign"], [8260, "fraction slash"], [215, "multiplication sign"], [185, "superscript one"], [178, "superscript two"], [179, "superscript three"], [188, "fraction one quarter"], [189, "fraction one half"], [190, "fraction three quarters"], [402, "function / florin"], [8747, "integral"], [8721, "n-ary sumation"], [8734, "infinity"], [8730, "square root"], [8764, "similar to"], [8773, "approximately equal to"], [8776, "almost equal to"], [8800, "not equal to"], [8801, "identical to"], [8712, "element of"], [8713, "not an element of"], [8715, "contains as member"], [8719, "n-ary product"], [8743, "logical and"], [8744, "logical or"], [172, "not sign"], [8745, "intersection"], [8746, "union"], [8706, "partial differential"], [8704, "for all"], [8707, "there exists"], [8709, "diameter"], [8711, "backward difference"], [8727, "asterisk operator"], [8733, "proportional to"], [8736, "angle"], [180, "acute accent"], [184, "cedilla"], [170, "feminine ordinal indicator"], [186, "masculine ordinal indicator"], [8224, "dagger"], [8225, "double dagger"], [192, "A - grave"], [193, "A - acute"], [194, "A - circumflex"], [195, "A - tilde"], [196, "A - diaeresis"], [197, "A - ring above"], [198, "ligature AE"], [199, "C - cedilla"], [200, "E - grave"], [201, "E - acute"], [202, "E - circumflex"], [203, "E - diaeresis"], [204, "I - grave"], [205, "I - acute"], [206, "I - circumflex"], [207, "I - diaeresis"], [208, "ETH"], [209, "N - tilde"], [210, "O - grave"], [211, "O - acute"], [212, "O - circumflex"], [213, "O - tilde"], [214, "O - diaeresis"], [216, "O - slash"], [338, "ligature OE"], [352, "S - caron"], [217, "U - grave"], [218, "U - acute"], [219, "U - circumflex"], [220, "U - diaeresis"], [221, "Y - acute"], [376, "Y - diaeresis"], [222, "THORN"], [224, "a - grave"], [225, "a - acute"], [226, "a - circumflex"], [227, "a - tilde"], [228, "a - diaeresis"], [229, "a - ring above"], [230, "ligature ae"], [231, "c - cedilla"], [232, "e - grave"], [233, "e - acute"], [234, "e - circumflex"], [235, "e - diaeresis"], [236, "i - grave"], [237, "i - acute"], [238, "i - circumflex"], [239, "i - diaeresis"], [240, "eth"], [241, "n - tilde"], [242, "o - grave"], [243, "o - acute"], [244, "o - circumflex"], [245, "o - tilde"], [246, "o - diaeresis"], [248, "o slash"], [339, "ligature oe"], [353, "s - caron"], [249, "u - grave"], [250, "u - acute"], [251, "u - circumflex"], [252, "u - diaeresis"], [253, "y - acute"], [254, "thorn"], [255, "y - diaeresis"], [913, "Alpha"], [914, "Beta"], [915, "Gamma"], [916, "Delta"], [917, "Epsilon"], [918, "Zeta"], [919, "Eta"], [920, "Theta"], [921, "Iota"], [922, "Kappa"], [923, "Lambda"], [924, "Mu"], [925, "Nu"], [926, "Xi"], [927, "Omicron"], [928, "Pi"], [929, "Rho"], [931, "Sigma"], [932, "Tau"], [933, "Upsilon"], [934, "Phi"], [935, "Chi"], [936, "Psi"], [937, "Omega"], [945, "alpha"], [946, "beta"], [947, "gamma"], [948, "delta"], [949, "epsilon"], [950, "zeta"], [951, "eta"], [952, "theta"], [953, "iota"], [954, "kappa"], [955, "lambda"], [956, "mu"], [957, "nu"], [958, "xi"], [959, "omicron"], [960, "pi"], [961, "rho"], [962, "final sigma"], [963, "sigma"], [964, "tau"], [965, "upsilon"], [966, "phi"], [967, "chi"], [968, "psi"], [969, "omega"], [8501, "alef symbol"], [982, "pi symbol"], [8476, "real part symbol"], [978, "upsilon - hook symbol"], [8472, "Weierstrass p"], [8465, "imaginary part"], [8592, "leftwards arrow"], [8593, "upwards arrow"], [8594, "rightwards arrow"], [8595, "downwards arrow"], [8596, "left right arrow"], [8629, "carriage return"], [8656, "leftwards double arrow"], [8657, "upwards double arrow"], [8658, "rightwards double arrow"], [8659, "downwards double arrow"], [8660, "left right double arrow"], [8756, "therefore"], [8834, "subset of"], [8835, "superset of"], [8836, "not a subset of"], [8838, "subset of or equal to"], [8839, "superset of or equal to"], [8853, "circled plus"], [8855, "circled times"], [8869, "perpendicular"], [8901, "dot operator"], [8968, "left ceiling"], [8969, "right ceiling"], [8970, "left floor"], [8971, "right floor"], [9001, "left-pointing angle bracket"], [9002, "right-pointing angle bracket"], [9674, "lozenge"], [9824, "black spade suit"], [9827, "black club suit"], [9829, "black heart suit"], [9830, "black diamond suit"], [8194, "en space"], [8195, "em space"], [8201, "thin space"], [8204, "zero width non-joiner"], [8205, "zero width joiner"], [8206, "left-to-right mark"], [8207, "right-to-left mark"] ]; // The markup that is kept, the same as the HTML Purifier allowlist in // lib/purify.inc.php. Other tags are removed and their contents kept. var ALLOWED = { P: ["style", "align"], BR: [], STRONG: [], B: [], EM: [], I: [], U: [], SUP: [], SUB: [], HR: [], PRE: [], CODE: [], BLOCKQUOTE: ["style"], DIV: ["style", "align"], SPAN: ["style"], UL: [], OL: [], LI: [], A: ["href", "title"] }; var RENAME = { B: "STRONG", I: "EM" }; var DROP = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, HEAD: 1, TITLE: 1, META: 1, LINK: 1 }; var STYLES = ["text-align", "padding-left", "margin-left"]; var BLOCK = /^(P|DIV|LI|PRE|BLOCKQUOTE|UL|OL|HR|H[1-6])$/; var VOID = { BR: 1, HR: 1 }; function icon(name) { var spec = ICONS[name]; var svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", "0 0 " + (spec.box || 16) + " " + (spec.box || 16)); svg.setAttribute("width", "16"); svg.setAttribute("height", "16"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); if (spec.fill) { svg.setAttribute("fill", "currentColor"); svg.setAttribute("fill-rule", "evenodd"); } else { svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", String(spec.width || 2.5)); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); } (spec.fill || spec.stroke).forEach(function (d) { var path = document.createElementNS(SVG_NS, "path"); path.setAttribute("d", d); svg.appendChild(path); }); return svg; } function el(tag, className, text) { var node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function safeUrl(url) { url = String(url || "").trim(); // Relative links, and only these schemes return /^(https?:|mailto:|[\/#?.]|[^:]*$)/i.test(url) ? url : ""; } // Keep only the allowed properties of a style attribute function cleanStyle(style) { var out = []; String(style || "").split(";").forEach(function (rule) { var i = rule.indexOf(":"); if (i < 0) return; var name = rule.slice(0, i).trim().toLowerCase(); var value = rule.slice(i + 1).trim(); if (STYLES.indexOf(name) >= 0 && /^[\w .%-]+$/.test(value)) out.push(name + ": " + value + ";"); }); return out.join(" "); } /* * Copy the allowed markup from *source* into *target*. The source nodes * come from an inert document (DOMParser), so nothing in them runs. */ function cleanInto(source, target) { Array.prototype.forEach.call(source.childNodes, function (node) { if (node.nodeType === 3) { target.appendChild(document.createTextNode(node.nodeValue)); return; } if (node.nodeType !== 1 || DROP[node.nodeName]) return; var name = node.nodeName; if (/^H[1-6]$/.test(name)) name = "P"; if (!ALLOWED[name]) { cleanInto(node, target); return; } var copy = document.createElement(RENAME[name] || name); ALLOWED[name].forEach(function (attr) { var value = node.getAttribute(attr); if (value === null) return; if (attr === "style") value = cleanStyle(value); if (attr === "href") value = safeUrl(value); if (attr === "align" && !/^(left|right|center|justify)$/i.test(value)) value = ""; if (value) copy.setAttribute(attr, value); }); // A span with no style left is just its contents if (name === "SPAN" && !copy.hasAttribute("style")) { cleanInto(node, target); return; } cleanInto(node, copy); target.appendChild(copy); }); return target; } function parse(html) { var doc = new DOMParser().parseFromString("
" + String(html || "") + "", "text/html"); return cleanInto(doc.body, document.createDocumentFragment()); } function escapeText(text) { return text.replace(/&/g, "&").replace(//g, ">").replace(/\u00a0/g, " "); } function escapeAttr(text) { return escapeText(text).replace(/"/g, """); } function isEmptyBlock(node) { return !node.textContent.replace(/[\s\u00a0\u200b]/g, "") && !node.querySelector("hr"); } /* * The HTML for the textarea, written the way TinyMCE wrote it: * and , one block per line, "
function tidy() {
var run = null;
var moved = false;
var range = currentRange();
var where = range && [range.startContainer, range.startOffset, range.endContainer, range.endOffset];
Array.prototype.slice.call(box.childNodes).forEach(function (node) {
var inline = node.nodeType === 3 ? !!(node.nodeValue.trim() || run) : node.nodeType === 1 && !BLOCK.test(node.nodeName);
if (inline) {
if (!run) {
run = document.createElement("p");
box.insertBefore(run, node);
}
run.appendChild(node);
moved = true;
} else {
run = null;
}
});
if (!box.firstElementChild) {
box.textContent = "";
var p = emptyParagraph();
box.appendChild(p);
if (where) where = [p, 0, p, 0];
moved = true;
}
// Moving a node loses a selection inside it; put the selection back
if (moved && where && box.contains(where[0]) && box.contains(where[2])) {
try {
var again = document.createRange();
again.setStart(where[0], where[1]);
again.setEnd(where[2], where[3]);
selectRange(again);
} catch (e) { }
}
}
function emptyParagraph() {
var p = document.createElement("p");
p.appendChild(document.createElement("br"));
return p;
}
function setContent(html) {
if (sourceMode) showEditor();
box.textContent = "";
box.appendChild(parse(html));
// " " in an empty paragraph becomes a
that holds the caret
Array.prototype.forEach.call(box.querySelectorAll("p, div, li"), function (block) {
if (isEmptyBlock(block) && !block.querySelector("br")) {
block.textContent = "";
block.appendChild(document.createElement("br"));
}
});
tidy();
sync();
}
/* ---------- selection ---------- */
function currentRange() {
var selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
var range = selection.getRangeAt(0);
return box.contains(range.commonAncestorContainer) ? range : null;
}
function selectRange(range) {
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function pathTo(node) {
var path = [];
while (node && node !== box) {
path.unshift(Array.prototype.indexOf.call(node.parentNode.childNodes, node));
node = node.parentNode;
}
return node === box ? path : null;
}
function nodeAt(path) {
var node = box;
for (var i = 0; node && i < path.length; i++) node = node.childNodes[path[i]];
return node;
}
function snapshot() {
var range = currentRange();
var shot = { html: box.innerHTML, range: null };
if (range) {
shot.range = [pathTo(range.startContainer), range.startOffset, pathTo(range.endContainer), range.endOffset];
}
return shot;
}
function restore(shot) {
box.innerHTML = shot.html;
if (shot.range && shot.range[0] && shot.range[2]) {
var start = nodeAt(shot.range[0]);
var end = nodeAt(shot.range[2]);
if (start && end) {
try {
var range = document.createRange();
range.setStart(start, shot.range[1]);
range.setEnd(end, shot.range[3]);
box.focus();
selectRange(range);
} catch (e) { }
}
}
sync();
refresh();
}
/* ---------- undo ---------- */
// Keep the state from before a change. Typing is kept as one step until
// the kind of input changes or there is a pause.
function remember(kind) {
var now = Date.now();
if (kind && kind === lastInput.type && now - lastInput.time < 1000) {
lastInput.time = now;
return;
}
lastInput = { time: now, type: kind || "" };
var shot = snapshot();
if (undoStack.length && undoStack[undoStack.length - 1].html === shot.html) return;
undoStack.push(shot);
if (undoStack.length > 100) undoStack.shift();
redoStack = [];
}
function undo() {
if (!undoStack.length) return;
redoStack.push(snapshot());
restore(undoStack.pop());
lastInput = { time: 0, type: "" };
}
function redo() {
if (!redoStack.length) return;
undoStack.push(snapshot());
restore(redoStack.pop());
lastInput = { time: 0, type: "" };
}
/* ---------- blocks ---------- */
function closest(node, test) {
while (node && node !== box) {
if (node.nodeType === 1 && test(node)) return node;
node = node.parentNode;
}
return null;
}
function closestTag(node, pattern) {
return closest(node, function (n) { return pattern.test(n.nodeName); });
}
// The innermost text blocks that the selection touches
function selectedBlocks() {
tidy();
var range = currentRange();
if (!range) return [];
var blocks = [];
Array.prototype.forEach.call(box.querySelectorAll("p, div, li, pre, blockquote"), function (block) {
if (block.nodeName === "BLOCKQUOTE" && block.querySelector("p, div, li, pre")) return;
if (range.intersectsNode(block)) blocks.push(block);
});
if (!blocks.length) {
var block = closestTag(range.startContainer, /^(P|DIV|LI|PRE|BLOCKQUOTE)$/);
if (block) blocks.push(block);
}
return blocks;
}
function align(value) {
var blocks = selectedBlocks();
if (!blocks.length) return;
// Pressing the alignment that is already set removes it, as in TinyMCE
var on = blocks.every(function (b) { return b.style.textAlign === value; });
blocks.forEach(function (b) {
b.removeAttribute("align");
b.style.textAlign = on ? "" : value;
if (!b.getAttribute("style")) b.removeAttribute("style");
});
}
function indent(step) {
var range = currentRange();
if (range && closestTag(range.startContainer, /^LI$/)) {
document.execCommand(step > 0 ? "indent" : "outdent", false, null);
return;
}
selectedBlocks().forEach(function (b) {
var now = parseInt(b.style.paddingLeft, 10) || 0;
var next = Math.max(0, now + step);
b.style.paddingLeft = next ? next + "px" : "";
if (!b.getAttribute("style")) b.removeAttribute("style");
});
}
// The ancestor of *node* that is a direct child of *parent*
function childOf(parent, node) {
while (node && node.parentNode !== parent) node = node.parentNode;
return node;
}
function previousElement(node) {
node = node.previousSibling;
while (node && node.nodeType === 3 && !node.nodeValue.trim()) node = node.previousSibling;
return node;
}
function unwrap(node) {
while (node.firstChild) node.parentNode.insertBefore(node.firstChild, node);
node.parentNode.removeChild(node);
}
// Wrap the selected blocks in a
, or take them out of one function blockquote() { var range = currentRange(); var blocks = selectedBlocks(); if (!range || !blocks.length) return; var saved = [range.startContainer, range.startOffset, range.endContainer, range.endOffset]; var quotes = blocks.map(function (b) { return closestTag(b, /^BLOCKQUOTE$/); }); if (quotes.every(Boolean)) { quotes.filter(function (q, i) { return quotes.indexOf(q) === i; }).forEach(function (quote) { if (!quote.querySelector("p, div, li, pre")) { var p = document.createElement("p"); while (quote.firstChild) p.appendChild(quote.firstChild); quote.appendChild(p); } unwrap(quote); }); } else { var tops = []; blocks.forEach(function (b) { var top = childOf(box, b); if (top && tops.indexOf(top) < 0) tops.push(top); }); var quote = null; tops.forEach(function (top) { if (!quote || previousElement(top) !== quote) { quote = document.createElement("blockquote"); box.insertBefore(quote, top); } if (top.nodeName === "BLOCKQUOTE") { while (top.firstChild) quote.appendChild(top.firstChild); top.parentNode.removeChild(top); } else { quote.appendChild(top); } }); } try { var again = document.createRange(); again.setStart(saved[0], saved[1]); again.setEnd(saved[2], saved[3]); selectRange(again); } catch (e) { } } /* ---------- links ---------- */ function linkAt(range) { if (!range) return null; var a = closestTag(range.startContainer, /^A$/); if (a) return a; // A selection of exactly one link var links = box.querySelectorAll("a"); for (var i = 0; i < links.length; i++) { if (range.intersectsNode(links[i])) return links[i]; } return null; } function linkDialog() { var range = currentRange(); savedRange = range ? range.cloneRange() : null; var link = linkAt(range); var text = link ? link.textContent : (range ? range.toString() : ""); var fields = [ { name: "href", label: "Url", value: link ? link.getAttribute("href") : "" }, { name: "text", label: "Text to display", value: text }, { name: "title", label: "Title", value: link ? link.getAttribute("title") || "" : "" } ]; var form = el("div", "xed-form"); var inputs = {}; fields.forEach(function (field) { var row = el("div", "xed-row"); var id = "xed-" + field.name + "-" + Math.random().toString(36).slice(2); var lab = el("label", "", field.label); lab.htmlFor = id; var input = el("input", "xed-textbox"); input.type = "text"; input.id = id; input.value = field.value; inputs[field.name] = input; row.appendChild(lab); row.appendChild(input); form.appendChild(row); }); // A selection with markup in it keeps its markup unless the text changes var textWas = inputs.text.value; openDialog("Insert link", form, [ { text: "Ok", primary: true, action: function () { // Without text, the link shows the address as it was typed var typed = inputs.href.value.trim(); var href = typed; if (/^www\./i.test(href)) href = "http://" + href; else if (/^[^\s:\/@]+@[^\s:\/@]+\.[^\s:\/@]+$/.test(href)) href = "mailto:" + href; href = safeUrl(href); restoreSelection(); remember(); if (!href) { if (link) unwrap(link); } else if (link) { link.setAttribute("href", href); setTitle(link, inputs.title.value); if (inputs.text.value !== textWas) link.textContent = inputs.text.value || typed; } else { insertLink(href, inputs.title.value, inputs.text.value || typed, inputs.text.value !== textWas); } changed(); } }, { text: "Cancel" } ], inputs.href); } function setTitle(link, title) { if (title.trim()) link.setAttribute("title", title.trim()); else link.removeAttribute("title"); } function insertLink(href, title, text, textChanged) { var range = currentRange(); if (!range) return; if (range.collapsed || textChanged) { var a = document.createElement("a"); a.setAttribute("href", href); setTitle(a, title); a.textContent = text; range.deleteContents(); range.insertNode(a); var after = document.createRange(); after.setStartAfter(a); selectRange(after); } else { var before = Array.prototype.slice.call(box.querySelectorAll("a")); document.execCommand("createLink", false, href); Array.prototype.forEach.call(box.querySelectorAll("a"), function (a) { if (before.indexOf(a) < 0) setTitle(a, title); }); } } function unlink() { var range = currentRange(); var link = linkAt(range); if (range && range.collapsed && link) { unwrap(link); } else { document.execCommand("unlink", false, null); } } /* ---------- special characters ---------- */ function charmapDialog() { var range = currentRange(); savedRange = range ? range.cloneRange() : null; var body = el("div", "xed-charmap-body"); var table = el("table", "xed-charmap"); var preview = el("div", "xed-charmap-preview"); var big = el("div", "xed-charmap-char", ""); var name = el("div", "xed-charmap-name", ""); preview.appendChild(big); preview.appendChild(name); var row = null; var dialog; CHARMAP.forEach(function (entry, i) { if (i % 25 === 0) row = table.insertRow(); var cell = row.insertCell(); var character = String.fromCharCode(entry[0]); cell.textContent = character; cell.title = entry[1]; cell.tabIndex = 0; cell.addEventListener("mouseover", function () { big.textContent = character; name.textContent = entry[1]; }); function choose() { dialog.close(); restoreSelection(); remember(); document.execCommand("insertText", false, character); changed(); } cell.addEventListener("click", choose); cell.addEventListener("keydown", function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(); } }); }); body.appendChild(table); body.appendChild(preview); dialog = openDialog("Special character", body, [{ text: "Close" }], null, true); } /* ---------- source code ---------- */ // Show the plain textarea in place of the editor, with the HTML in it function showSource() { sync(); sourceMode = true; box.hidden = true; textarea.hidden = false; textarea.style.display = ""; Object.keys(buttons).forEach(function (command) { if (command !== "code") buttons[command].disabled = true; }); buttons.code.classList.add("xed-active"); buttons.code.setAttribute("aria-pressed", "true"); textarea.focus(); } // Back to the editor, with the HTML from the textarea (cleaned) function showEditor() { sourceMode = false; textarea.hidden = true; textarea.style.display = "none"; box.hidden = false; Object.keys(buttons).forEach(function (command) { buttons[command].disabled = false; }); buttons.code.classList.remove("xed-active"); buttons.code.setAttribute("aria-pressed", "false"); } function toggleSource() { if (!sourceMode) { showSource(); return; } var html = textarea.value; remember(); showEditor(); setContent(html); box.focus(); changed(); } function restoreSelection() { box.focus(); if (savedRange) selectRange(savedRange); savedRange = null; } /* ---------- dialogs ---------- */ // A modal window. With *light*, a click outside the window closes it. function openDialog(title, content, actions, focus, light) { var block = el("div", "xed-modal-block" + (light ? " xed-modal-light" : "")); var win = el("div", "xed-window"); win.setAttribute("role", "dialog"); win.setAttribute("aria-modal", "true"); var head = el("div", "xed-window-head"); var heading = el("div", "xed-title", title); heading.id = "xed-title-" + Math.random().toString(36).slice(2); win.setAttribute("aria-labelledby", heading.id); var close = el("button", "xed-close", "\u00d7"); close.type = "button"; close.setAttribute("aria-label", "Close"); head.appendChild(heading); head.appendChild(close); var body = el("div", "xed-window-body"); body.appendChild(content); var foot = el("div", "xed-foot"); var dialog = { close: function () { document.removeEventListener("keydown", onKey, true); block.parentNode.removeChild(block); win.parentNode.removeChild(win); } }; function cancel() { dialog.close(); restoreSelection(); } actions.forEach(function (action) { var button = el("button", "xed-button" + (action.primary ? " xed-primary" : ""), action.text); button.type = "button"; button.addEventListener("click", function () { if (action.action) { dialog.close(); action.action(); } else { cancel(); } }); foot.appendChild(button); }); close.addEventListener("click", cancel); if (light) block.addEventListener("click", cancel); function onKey(event) { if (event.key === "Escape") { event.preventDefault(); cancel(); } else if (event.key === "Enter" && event.target.nodeName === "INPUT") { event.preventDefault(); var primary = foot.querySelector(".xed-primary"); if (primary) primary.click(); } else if (event.key === "Tab") { // Keep the focus inside the window var items = Array.prototype.filter.call(win.querySelectorAll("button, input, textarea, [tabindex]"), function (n) { return n.tabIndex >= 0; }); var first = items[0]; var last = items[items.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } } document.addEventListener("keydown", onKey, true); win.appendChild(head); win.appendChild(body); win.appendChild(foot); document.body.appendChild(block); document.body.appendChild(win); (focus || foot.querySelector("button")).focus(); return dialog; } /* ---------- toolbar ---------- */ function run(command) { if (command === "link") return linkDialog(); if (command === "charmap") return charmapDialog(); if (command === "code") return toggleSource(); box.focus(); if (!currentRange()) { // No caret yet: start at the end var range = document.createRange(); range.selectNodeContents(box.lastChild || box); range.collapse(false); selectRange(range); } remember(); if (command === "bold" || command === "italic") { document.execCommand("styleWithCSS", false, false); document.execCommand(command, false, null); } else if (ALIGN[command]) { align(ALIGN[command]); } else if (command === "indent") { indent(INDENT_STEP); } else if (command === "outdent") { indent(-INDENT_STEP); } else if (command === "blockquote") { blockquote(); } else if (command === "unlink") { unlink(); } changed(); } function isActive(command, range) { var node = range.startContainer; if (command === "bold" || command === "italic") { try { return document.queryCommandState(command); } catch (e) { return !!closestTag(node, command === "bold" ? /^(STRONG|B)$/ : /^(EM|I)$/); } } if (ALIGN[command]) { var block = closestTag(node, /^(P|DIV|LI|PRE|BLOCKQUOTE)$/); return !!block && (block.style.textAlign || (block.getAttribute("align") || "").toLowerCase()) === ALIGN[command]; } if (command === "blockquote") return !!closestTag(node, /^BLOCKQUOTE$/); if (command === "link") return !!closestTag(node, /^A$/); return false; } // Show which formats apply where the caret is function refresh() { var range = document.activeElement === box ? currentRange() : null; Object.keys(buttons).forEach(function (command) { if (command === "code") return; var on = !!range && isActive(command, range); buttons[command].classList.toggle("xed-active", on); if (buttons[command].hasAttribute("aria-pressed")) buttons[command].setAttribute("aria-pressed", on ? "true" : "false"); }); } function changed() { tidy(); sync(); refresh(); } TOOLBAR.forEach(function (group) { var node = el("div", "xed-group"); group.forEach(function (spec) { var button = el("button", "xed-btn"); button.type = "button"; button.title = spec.title + (spec.key ? " (Ctrl+" + spec.key.toUpperCase() + ")" : ""); button.setAttribute("aria-label", spec.title); if (/^(bold|italic|align|blockquote|code)/.test(spec.command)) button.setAttribute("aria-pressed", "false"); button.appendChild(icon(spec.command)); // Keep the selection in the editor when the button is pressed button.addEventListener("mousedown", function (event) { event.preventDefault(); }); button.addEventListener("click", function () { run(spec.command); }); buttons[spec.command] = button; node.appendChild(button); }); toolbar.appendChild(node); }); /* ---------- events ---------- */ box.addEventListener("beforeinput", function (event) { if (event.inputType === "historyUndo") { event.preventDefault(); undo(); } else if (event.inputType === "historyRedo") { event.preventDefault(); redo(); } else { remember(event.inputType); } }); box.addEventListener("input", function (event) { // After everything is deleted, start again with a plain paragraph, so // that the formatting of the deleted text does not carry on if (/^delete/.test(event.inputType || "") && isEmptyBlock(box) && !box.querySelector("li")) { box.textContent = ""; var p = emptyParagraph(); box.appendChild(p); var range = document.createRange(); range.setStart(p, 0); range.collapse(true); selectRange(range); } changed(); }); box.addEventListener("keydown", function (event) { if (!(event.ctrlKey || event.metaKey) || event.altKey) return; var key = event.key.toLowerCase(); if (key === "z" && !event.shiftKey) { event.preventDefault(); undo(); } else if (key === "y" || (key === "z" && event.shiftKey)) { event.preventDefault(); redo(); } else { TOOLBAR.forEach(function (group) { group.forEach(function (spec) { if (spec.key === key && !event.shiftKey) { event.preventDefault(); run(spec.command); } }); }); } }); // Paste the text and its simple formatting, not the styles of the page // it came from box.addEventListener("paste", function (event) { var data = event.clipboardData || window.clipboardData; if (!data) return; event.preventDefault(); remember(); var html = data.getData("text/html"); if (html) { var holder = document.createElement("div"); holder.appendChild(parse(html)); document.execCommand("insertHTML", false, holder.innerHTML); } else { // A blank line starts a new paragraph; a single new line is a line break var text = data.getData("text/plain").replace(/\r\n?/g, "\n"); text.split(/\n[ \t]*\n\s*/).forEach(function (para, i) { if (i) document.execCommand("insertParagraph", false, null); para.split("\n").forEach(function (line, j) { if (j) document.execCommand("insertLineBreak", false, null); if (line) document.execCommand("insertText", false, line); }); }); } changed(); }); box.addEventListener("focus", refresh); box.addEventListener("blur", function () { sync(); refresh(); }); document.addEventListener("selectionchange", refresh); // The textarea keeps its place in the form, hidden behind the editor textarea.parentNode.insertBefore(wrapper, textarea); wrapper.appendChild(toolbar); wrapper.appendChild(box); wrapper.appendChild(textarea); // The site style sets display on #story, which beats the hidden attribute textarea.hidden = true; textarea.style.display = "none"; textarea.classList.add("xed-source"); setContent(textarea.value); if (textarea.form) textarea.form.addEventListener("submit", sync); return { getContent: function () { return sourceMode ? textarea.value : serialize(box); }, setContent: function (html) { setContent(html); }, focus: function () { (sourceMode ? textarea : box).focus(); } }; } return { attach: attach, serialize: serialize, parse: parse }; })();