Files
xxx/publish/editor.js
T
tim babe5361e7 Edit the story source in the textarea.
Also close the special character window on a click outside it.
2026-09-26 00:10:14 +02:00

1102 lines
42 KiB
JavaScript

/*
* 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 <p style="text-align: center;"> 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("<body>" + String(html || "") + "</body>", "text/html");
return cleanInto(doc.body, document.createDocumentFragment());
}
function escapeText(text) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\u00a0/g, "&nbsp;");
}
function escapeAttr(text) {
return escapeText(text).replace(/"/g, "&quot;");
}
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: <strong>
* and <em>, one block per line, "<br />", and "&nbsp;" in an empty
* paragraph. An editor with no words in it gives "".
*/
function serialize(root) {
function inner(node, depth) {
var html = "";
Array.prototype.forEach.call(node.childNodes, function (child) {
html += outer(child, depth);
});
return html;
}
function outer(node, depth) {
if (node.nodeType === 3) {
// Whitespace between blocks is only layout
if (depth === 0 && !node.nodeValue.trim()) return "";
return escapeText(node.nodeValue);
}
if (node.nodeType !== 1) return "";
var name = RENAME[node.nodeName] || node.nodeName;
if (!ALLOWED[name]) return inner(node, depth);
// Chrome can leave a <span style="..."> behind; keep only allowed styles
var attrs = "";
(ALLOWED[name] || []).forEach(function (attr) {
var value = node.getAttribute(attr);
if (attr === "style") value = cleanStyle(value);
if (attr === "href") value = safeUrl(value);
if (value) attrs += " " + attr + '="' + escapeAttr(value) + '"';
});
if (name === "SPAN" && !attrs) return inner(node, depth);
var tag = name.toLowerCase();
if (VOID[name]) return "<" + tag + attrs + " />";
var block = BLOCK.test(name);
var body;
if (/^(P|DIV|LI|PRE)$/.test(name) && isEmptyBlock(node)) {
body = "&nbsp;";
} else {
body = inner(node, /^(BLOCKQUOTE|UL|OL)$/.test(name) ? 0 : depth + 1);
// A trailing <br> that the browser keeps to hold the caret
if (block) body = body.replace(/<br \/>$/, "");
}
if (/^(BLOCKQUOTE|UL|OL)$/.test(name)) body = "\n" + body;
return "<" + tag + attrs + ">" + body + "</" + tag + ">" + (block ? "\n" : "");
}
if (isEmptyBlock(root)) return "";
return inner(root, 0).trim();
}
function attach(textarea) {
var wrapper = el("div", "xed");
var box = el("div", "xed-area");
box.contentEditable = "true";
box.spellcheck = true;
box.setAttribute("role", "textbox");
box.setAttribute("aria-multiline", "true");
var label = textarea.id && document.querySelector('label[for="' + textarea.id + '"]');
box.setAttribute("aria-label", label ? label.textContent.trim() : "Story");
var toolbar = el("div", "xed-toolbar");
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "Formatting");
var buttons = {};
var undoStack = [];
var redoStack = [];
var lastInput = { time: 0, type: "" };
var savedRange = null;
var sourceMode = false;
// Paragraphs are the default block, as in TinyMCE
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) { }
/* ---------- content ---------- */
function sync() {
// In source mode the textarea itself holds the story
if (sourceMode) return;
var html = serialize(box);
if (textarea.value !== html) {
textarea.value = html;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
}
// Put loose text at the top level into paragraphs, and keep one empty
// paragraph in an empty editor so that typing starts in a <p>
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));
// "&nbsp;" in an empty paragraph becomes a <br> 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 <blockquote>, 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 () {
var href = inputs.href.value.trim();
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 || href;
} else {
insertLink(href, inputs.title.value, inputs.text.value, 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 || href;
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 };
})();