1066 lines
41 KiB
JavaScript
1066 lines
41 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.
|
|
*
|
|
* 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, "&").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: <strong>
|
|
* and <em>, one block per line, "<br />", and " " 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 = " ";
|
|
} 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;
|
|
|
|
// Paragraphs are the default block, as in TinyMCE
|
|
try { document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) { }
|
|
|
|
/* ---------- content ---------- */
|
|
|
|
function sync() {
|
|
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) {
|
|
box.textContent = "";
|
|
box.appendChild(parse(html));
|
|
// " " 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" }]);
|
|
}
|
|
|
|
/* ---------- source code ---------- */
|
|
|
|
function codeDialog() {
|
|
var source = el("textarea", "xed-source");
|
|
source.value = serialize(box);
|
|
source.spellcheck = false;
|
|
openDialog("Source code", source, [
|
|
{ text: "Ok", primary: true, action: function () {
|
|
remember();
|
|
setContent(source.value);
|
|
box.focus();
|
|
changed();
|
|
} },
|
|
{ text: "Cancel" }
|
|
], source, "xed-window-code");
|
|
}
|
|
|
|
function restoreSelection() {
|
|
box.focus();
|
|
if (savedRange) selectRange(savedRange);
|
|
savedRange = null;
|
|
}
|
|
|
|
/* ---------- dialogs ---------- */
|
|
|
|
// A modal window in the style of the TinyMCE skin
|
|
function openDialog(title, content, actions, focus, extraClass) {
|
|
var block = el("div", "xed-modal-block");
|
|
var win = el("div", "xed-window" + (extraClass ? " " + extraClass : ""));
|
|
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);
|
|
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 codeDialog();
|
|
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) {
|
|
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)/.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";
|
|
setContent(textarea.value);
|
|
if (textarea.form) textarea.form.addEventListener("submit", sync);
|
|
|
|
return {
|
|
getContent: function () { return serialize(box); },
|
|
setContent: function (html) { setContent(html); },
|
|
focus: function () { box.focus(); }
|
|
};
|
|
}
|
|
|
|
return { attach: attach, serialize: serialize, parse: parse };
|
|
})();
|