@richscripts/richtexteditor 2.7.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +146 -146
- package/richtexteditor/all_plugins.js +1563 -0
- package/richtexteditor/all_plugins.min.js +59 -58
- package/richtexteditor/plugins/all_plugins.js +1563 -0
- package/richtexteditor/plugins/all_plugins.min.js +59 -58
- package/richtexteditor/plugins/nodetypes.js +439 -0
- package/richtexteditor/plugins/rubyannotation.js +255 -0
- package/richtexteditor/plugins/stateinspector.js +866 -0
- package/richtexteditor/rte.js +45 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
|
|
2
|
+
|
|
3
|
+
RTE_DefaultConfig.plugin_nodetypes = RTE_Plugin_NodeTypes;
|
|
4
|
+
|
|
5
|
+
// 2026-09-04 Custom node type registry.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THE GAP ACTUALLY IS. Measured before this was designed, rather than
|
|
8
|
+
// assumed. A custom element with data-* attributes already survives more of this
|
|
9
|
+
// editor than expected:
|
|
10
|
+
//
|
|
11
|
+
// setHTML -> getHTML round trip ......... survives, attributes intact
|
|
12
|
+
// contenteditable="false" atomicity ..... preserved
|
|
13
|
+
// undo of an editor-driven delete ....... restored, attributes intact
|
|
14
|
+
// sanitizer ............................. already extensible via
|
|
15
|
+
// config.sanitizerAllowTags / -Attributes
|
|
16
|
+
// markdown export ....................... DEGRADES to the element's text
|
|
17
|
+
// .docx export .......................... DEGRADES to the element's text
|
|
18
|
+
// an API to declare any of this ......... DID NOT EXIST
|
|
19
|
+
//
|
|
20
|
+
// So the gap is not "you cannot put your own element in the document" - you can.
|
|
21
|
+
// The gap is that nothing lets you DECLARE that the element is a node type, and
|
|
22
|
+
// therefore nothing downstream can treat it deliberately. Degrading to text in
|
|
23
|
+
// Markdown and .docx is a defensible default; having no way to say what should
|
|
24
|
+
// happen instead is not.
|
|
25
|
+
//
|
|
26
|
+
// This registry is that declaration. Register a type once and it gets:
|
|
27
|
+
// - its tags and attributes allowed through the sanitizer automatically
|
|
28
|
+
// - atomicity enforced and repaired (chips stop being atomic the moment
|
|
29
|
+
// contenteditable is lost, and then the caret edits their internals)
|
|
30
|
+
// - JSON serialization of every instance, for saving state outside the HTML
|
|
31
|
+
// - a Markdown representation you control instead of raw text content
|
|
32
|
+
//
|
|
33
|
+
// ORDERING NOTE: plugins initialise in alphabetical bundle order. "nodetypes"
|
|
34
|
+
// sorts BEFORE "sanitizer" and BEFORE "slashcommand", so this file must NOT call
|
|
35
|
+
// editor.slashCommands.register() from init - it would silently no-op. Feeding
|
|
36
|
+
// the sanitizer works precisely because we run first: we write config before
|
|
37
|
+
// sanitizer.js reads it.
|
|
38
|
+
function RTE_Plugin_NodeTypes() {
|
|
39
|
+
var obj = this;
|
|
40
|
+
var config;
|
|
41
|
+
var editor;
|
|
42
|
+
var types = []; // registration order
|
|
43
|
+
var byName = {};
|
|
44
|
+
var enforceTimer = 0;
|
|
45
|
+
var originalToMarkdown = null;
|
|
46
|
+
|
|
47
|
+
obj.PluginName = "NodeTypes";
|
|
48
|
+
|
|
49
|
+
obj.InitConfig = function (argconfig) {
|
|
50
|
+
config = argconfig;
|
|
51
|
+
if (config.nodeTypesEnabled === false) return;
|
|
52
|
+
|
|
53
|
+
// Types can be declared in config so a host does not have to wait for an
|
|
54
|
+
// editor instance to exist.
|
|
55
|
+
if (Array.isArray(config.nodeTypes)) {
|
|
56
|
+
for (var i = 0; i < config.nodeTypes.length; i++) registerType(config.nodeTypes[i]);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
obj.InitEditor = function (argeditor) {
|
|
61
|
+
editor = argeditor;
|
|
62
|
+
if (config.nodeTypesEnabled === false) return;
|
|
63
|
+
|
|
64
|
+
editor.nodeTypes = {
|
|
65
|
+
find: function (name) { return findInstances(name); },
|
|
66
|
+
get: function (name) { return byName[name] || null; },
|
|
67
|
+
insert: function (name, data) { return insertNode(name, data); },
|
|
68
|
+
list: function () { return types.map(describe); },
|
|
69
|
+
register: function (def) { return registerType(def); },
|
|
70
|
+
serialize: function () { return serializeAll(); },
|
|
71
|
+
typeOf: function (el) { var t = typeFor(el); return t ? t.name : null; },
|
|
72
|
+
unregister: function (name) { return unregisterType(name); }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Atomicity is a live invariant, not a one-off: a paste, an undo or a
|
|
76
|
+
// competing plugin can strip contenteditable and the node silently stops
|
|
77
|
+
// behaving like one unit.
|
|
78
|
+
//
|
|
79
|
+
// Observed rather than event-driven, deliberately. setHTML() does not
|
|
80
|
+
// fire "change", so an invariant hung off editor events is not enforced
|
|
81
|
+
// on loaded content at all - which is the most common way a node enters
|
|
82
|
+
// the document. Measured: with only the "change" handler, a node loaded
|
|
83
|
+
// via setHTML never gained contenteditable="false".
|
|
84
|
+
editor.attachEvent("change", function () { scheduleEnforce(); });
|
|
85
|
+
observeEditable();
|
|
86
|
+
|
|
87
|
+
wrapMarkdown();
|
|
88
|
+
|
|
89
|
+
// Deferred deliberately. Plugins initialise in alphabetical bundle order:
|
|
90
|
+
// docxexport (19) runs BEFORE nodetypes (49), but pdfexport (51) and
|
|
91
|
+
// wordexport (72) run AFTER, so editor.getPdfBytes does not exist yet at
|
|
92
|
+
// this point. Wrapping now would silently miss PDF entirely - the same
|
|
93
|
+
// ordering trap that makes slashCommands.register() a no-op from here.
|
|
94
|
+
// A zero-delay timeout lands after every plugin has initialised.
|
|
95
|
+
setTimeout(installExportHooks, 0);
|
|
96
|
+
|
|
97
|
+
enforceAll();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ------------------------------------------------------------ registration
|
|
101
|
+
|
|
102
|
+
function registerType(def) {
|
|
103
|
+
if (!def || !def.name) return false;
|
|
104
|
+
if (typeof def.match !== "string" && typeof def.match !== "function") return false;
|
|
105
|
+
if (byName[def.name]) unregisterType(def.name);
|
|
106
|
+
|
|
107
|
+
var type = {
|
|
108
|
+
name: def.name,
|
|
109
|
+
match: def.match,
|
|
110
|
+
atomic: def.atomic !== false, // atomic by default
|
|
111
|
+
inline: !!def.inline,
|
|
112
|
+
toJSON: typeof def.toJSON === "function" ? def.toJSON : null,
|
|
113
|
+
fromJSON: typeof def.fromJSON === "function" ? def.fromJSON : null,
|
|
114
|
+
toMarkdown: typeof def.toMarkdown === "function" ? def.toMarkdown : null,
|
|
115
|
+
// Export representations. toExportHTML covers .docx and PDF at once;
|
|
116
|
+
// toDocx / toPdf override it per format when they differ.
|
|
117
|
+
toExportHTML: typeof def.toExportHTML === "function" ? def.toExportHTML : null,
|
|
118
|
+
toDocx: typeof def.toDocx === "function" ? def.toDocx : null,
|
|
119
|
+
toPdf: typeof def.toPdf === "function" ? def.toPdf : null,
|
|
120
|
+
toWord: typeof def.toWord === "function" ? def.toWord : null,
|
|
121
|
+
allowTags: def.allowTags || [],
|
|
122
|
+
allowAttributes: def.allowAttributes || []
|
|
123
|
+
};
|
|
124
|
+
types.push(type);
|
|
125
|
+
byName[type.name] = type;
|
|
126
|
+
feedSanitizer(type);
|
|
127
|
+
if (editor) { enforceAll(); }
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function unregisterType(name) {
|
|
132
|
+
if (!byName[name]) return false;
|
|
133
|
+
types = types.filter(function (t) { return t.name !== name; });
|
|
134
|
+
delete byName[name];
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describe(t) {
|
|
139
|
+
return {
|
|
140
|
+
name: t.name, atomic: t.atomic, inline: t.inline,
|
|
141
|
+
hasToJSON: !!t.toJSON, hasFromJSON: !!t.fromJSON, hasToMarkdown: !!t.toMarkdown,
|
|
142
|
+
hasExportHTML: !!(t.toExportHTML || t.toDocx || t.toPdf),
|
|
143
|
+
instances: findInstances(t.name).length
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The sanitizer is an allowlist: an unknown tag is dropped on input, on
|
|
148
|
+
// output and from the live DOM. A registered type that did not feed the
|
|
149
|
+
// allowlist would be deleted by the editor's own security layer, so this is
|
|
150
|
+
// wired automatically rather than left to the host to remember.
|
|
151
|
+
function feedSanitizer(type) {
|
|
152
|
+
if (!config) return;
|
|
153
|
+
mergeInto("sanitizerAllowTags", type.allowTags);
|
|
154
|
+
mergeInto("sanitizerAllowAttributes", type.allowAttributes);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mergeInto(key, values) {
|
|
158
|
+
if (!values || !values.length) return;
|
|
159
|
+
var list = config[key];
|
|
160
|
+
if (!Array.isArray(list)) list = list ? [list] : [];
|
|
161
|
+
for (var i = 0; i < values.length; i++) {
|
|
162
|
+
if (list.indexOf(values[i]) === -1) list.push(values[i]);
|
|
163
|
+
}
|
|
164
|
+
config[key] = list;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ------------------------------------------------------------- instances
|
|
168
|
+
|
|
169
|
+
function matches(type, el) {
|
|
170
|
+
if (!el || el.nodeType !== 1) return false;
|
|
171
|
+
if (typeof type.match === "string") {
|
|
172
|
+
try { return el.matches(type.match); } catch (e) { return false; }
|
|
173
|
+
}
|
|
174
|
+
try { return !!type.match(el); } catch (e) { return false; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function typeFor(el) {
|
|
178
|
+
for (var i = 0; i < types.length; i++) {
|
|
179
|
+
if (matches(types[i], el)) return types[i];
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function editable() {
|
|
185
|
+
return editor && editor.getEditable ? editor.getEditable() : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function findInstances(name) {
|
|
189
|
+
var root = editable();
|
|
190
|
+
var type = byName[name];
|
|
191
|
+
var out = [];
|
|
192
|
+
if (!root || !type) return out;
|
|
193
|
+
// Include the root's own children: querySelectorAll finds descendants
|
|
194
|
+
// only, and a node pasted at the top level is a child of the root. That
|
|
195
|
+
// exact blind spot is why the accessibility checker used to miss
|
|
196
|
+
// top-level images.
|
|
197
|
+
var all = root.querySelectorAll("*");
|
|
198
|
+
for (var i = 0; i < all.length; i++) {
|
|
199
|
+
if (matches(type, all[i])) out.push(all[i]);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function allInstances() {
|
|
205
|
+
var root = editable();
|
|
206
|
+
var out = [];
|
|
207
|
+
if (!root) return out;
|
|
208
|
+
var all = root.querySelectorAll("*");
|
|
209
|
+
for (var i = 0; i < all.length; i++) {
|
|
210
|
+
var t = typeFor(all[i]);
|
|
211
|
+
if (t) out.push({ type: t, el: all[i] });
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function insertNode(name, data) {
|
|
217
|
+
var type = byName[name];
|
|
218
|
+
if (!type || !type.fromJSON || !editor) return null;
|
|
219
|
+
var produced = type.fromJSON(data || {});
|
|
220
|
+
var html = typeof produced === "string" ? produced : (produced && produced.outerHTML);
|
|
221
|
+
if (!html) return null;
|
|
222
|
+
editor.insertHTML(html);
|
|
223
|
+
scheduleEnforce();
|
|
224
|
+
return html;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function serializeAll() {
|
|
228
|
+
var out = [];
|
|
229
|
+
var found = allInstances();
|
|
230
|
+
for (var i = 0; i < found.length; i++) {
|
|
231
|
+
var t = found[i].type, el = found[i].el;
|
|
232
|
+
out.push({
|
|
233
|
+
name: t.name,
|
|
234
|
+
data: t.toJSON ? safe(t.toJSON, el, {}) : readDataAttributes(el)
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readDataAttributes(el) {
|
|
241
|
+
var data = {};
|
|
242
|
+
var attrs = el.attributes;
|
|
243
|
+
for (var i = 0; attrs && i < attrs.length; i++) {
|
|
244
|
+
if (attrs[i].name.indexOf("data-") === 0) data[attrs[i].name] = attrs[i].value;
|
|
245
|
+
}
|
|
246
|
+
return data;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function safe(fn, arg, fallback) {
|
|
250
|
+
try { return fn(arg); } catch (e) { return fallback; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// -------------------------------------------------------------- atomicity
|
|
254
|
+
|
|
255
|
+
function observeEditable() {
|
|
256
|
+
var root = editable();
|
|
257
|
+
if (!root || typeof MutationObserver === "undefined") return;
|
|
258
|
+
var observer = new MutationObserver(function () { scheduleEnforce(); });
|
|
259
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["contenteditable"] });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scheduleEnforce() {
|
|
263
|
+
clearTimeout(enforceTimer);
|
|
264
|
+
enforceTimer = setTimeout(enforceAll, 60);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function enforceAll() {
|
|
268
|
+
var found = allInstances();
|
|
269
|
+
for (var i = 0; i < found.length; i++) {
|
|
270
|
+
if (found[i].type.atomic && found[i].el.getAttribute("contenteditable") !== "false") {
|
|
271
|
+
found[i].el.setAttribute("contenteditable", "false");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ----------------------------------------------------------------- export
|
|
277
|
+
|
|
278
|
+
// .docx and PDF both build from the live editable, so the same swap the
|
|
279
|
+
// Markdown path uses works here: replace each registered node with the HTML
|
|
280
|
+
// the type says represents it, export, then put the originals back.
|
|
281
|
+
//
|
|
282
|
+
// Unlike Markdown these are partly ASYNCHRONOUS - getDocxBlob returns a
|
|
283
|
+
// Promise - so the restore has to wait for the result to settle. Restoring
|
|
284
|
+
// synchronously would put the originals back while the exporter was still
|
|
285
|
+
// reading the DOM.
|
|
286
|
+
function installExportHooks() {
|
|
287
|
+
wrapExport("getDocxBlob", "toDocx");
|
|
288
|
+
wrapExport("exportToDocx", "toDocx");
|
|
289
|
+
wrapExport("getPdfBytes", "toPdf");
|
|
290
|
+
wrapExport("exportToPdf", "toPdf");
|
|
291
|
+
wrapExport("downloadPdf", "toPdf");
|
|
292
|
+
// wordexport.js sorts at 72 - after this file at 49 - which is exactly
|
|
293
|
+
// why the install is deferred. It builds from editor.getHTMLCode(), and
|
|
294
|
+
// getHTMLCode() serializes the live DOM, so the same swap reaches it.
|
|
295
|
+
wrapExport("getWordDocument", "toWord");
|
|
296
|
+
wrapExport("exportToWord", "toWord");
|
|
297
|
+
wrapExport("downloadWord", "toWord");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function wrapExport(apiName, hookName) {
|
|
301
|
+
if (!editor || typeof editor[apiName] !== "function") return;
|
|
302
|
+
var original = editor[apiName];
|
|
303
|
+
editor[apiName] = function () {
|
|
304
|
+
var args = arguments;
|
|
305
|
+
var found = allInstances().filter(function (f) {
|
|
306
|
+
return f.type[hookName] || f.type.toExportHTML;
|
|
307
|
+
});
|
|
308
|
+
if (!found.length) return original.apply(editor, args);
|
|
309
|
+
|
|
310
|
+
var restore = swapNodes(found, hookName);
|
|
311
|
+
var result;
|
|
312
|
+
try {
|
|
313
|
+
result = original.apply(editor, args);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
restore();
|
|
316
|
+
throw e;
|
|
317
|
+
}
|
|
318
|
+
if (result && typeof result.then === "function") {
|
|
319
|
+
return result.then(function (v) { restore(); return v; },
|
|
320
|
+
function (e) { restore(); throw e; });
|
|
321
|
+
}
|
|
322
|
+
restore();
|
|
323
|
+
return result;
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Replaces each instance with its declared export HTML and returns an
|
|
328
|
+
// idempotent restore function.
|
|
329
|
+
function swapNodes(found, hookName) {
|
|
330
|
+
var doc = editor.getDocument();
|
|
331
|
+
var swaps = [];
|
|
332
|
+
var savedRange = null;
|
|
333
|
+
try {
|
|
334
|
+
var sel = doc.getSelection();
|
|
335
|
+
if (sel && sel.rangeCount) savedRange = sel.getRangeAt(0).cloneRange();
|
|
336
|
+
} catch (e) { savedRange = null; }
|
|
337
|
+
|
|
338
|
+
for (var i = 0; i < found.length; i++) {
|
|
339
|
+
var el = found[i].el;
|
|
340
|
+
var fn = found[i].type[hookName] || found[i].type.toExportHTML;
|
|
341
|
+
var html = fn ? safe(fn, el, null) : null;
|
|
342
|
+
if (html === null || html === undefined) continue;
|
|
343
|
+
var holder = doc.createElement(found[i].type.inline ? "span" : "div");
|
|
344
|
+
holder.innerHTML = String(html);
|
|
345
|
+
el.parentNode.insertBefore(holder, el);
|
|
346
|
+
el.parentNode.removeChild(el);
|
|
347
|
+
swaps.push({ holder: holder, original: el });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
var done = false;
|
|
351
|
+
return function () {
|
|
352
|
+
if (done) return;
|
|
353
|
+
done = true;
|
|
354
|
+
for (var j = swaps.length - 1; j >= 0; j--) {
|
|
355
|
+
var s = swaps[j];
|
|
356
|
+
if (s.holder.parentNode) {
|
|
357
|
+
s.holder.parentNode.insertBefore(s.original, s.holder);
|
|
358
|
+
s.holder.parentNode.removeChild(s.holder);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (savedRange) {
|
|
362
|
+
try {
|
|
363
|
+
var sel2 = doc.getSelection();
|
|
364
|
+
sel2.removeAllRanges();
|
|
365
|
+
sel2.addRange(savedRange);
|
|
366
|
+
} catch (e) { /* selection may no longer be placeable */ }
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --------------------------------------------------------------- markdown
|
|
372
|
+
|
|
373
|
+
// The core's markdown engine takes a root element, but the public
|
|
374
|
+
// editor.toMarkdown() closes over the live editable and accepts no argument,
|
|
375
|
+
// so there is no way to hand it a modified clone. The only seam is to swap
|
|
376
|
+
// registered nodes for their markdown text in the live DOM, convert, and put
|
|
377
|
+
// the originals back.
|
|
378
|
+
//
|
|
379
|
+
// That mutates the document the user is editing, so it is wrapped in
|
|
380
|
+
// try/finally: if a host's toMarkdown throws, the originals are restored
|
|
381
|
+
// anyway. Selection is saved and restored for the same reason.
|
|
382
|
+
function wrapMarkdown() {
|
|
383
|
+
if (!editor || typeof editor.toMarkdown !== "function") return;
|
|
384
|
+
originalToMarkdown = editor.toMarkdown;
|
|
385
|
+
editor.toMarkdown = function () {
|
|
386
|
+
var found = allInstances().filter(function (f) { return f.type.toMarkdown; });
|
|
387
|
+
if (!found.length) return originalToMarkdown.call(editor);
|
|
388
|
+
|
|
389
|
+
var doc = editor.getDocument();
|
|
390
|
+
var swaps = [];
|
|
391
|
+
var tokens = [];
|
|
392
|
+
var savedRange = null;
|
|
393
|
+
try {
|
|
394
|
+
var sel = doc.getSelection();
|
|
395
|
+
if (sel && sel.rangeCount) savedRange = sel.getRangeAt(0).cloneRange();
|
|
396
|
+
} catch (e) { savedRange = null; }
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
for (var i = 0; i < found.length; i++) {
|
|
400
|
+
var el = found[i].el;
|
|
401
|
+
var md = safe(found[i].type.toMarkdown, el, null);
|
|
402
|
+
if (md === null || md === undefined) continue;
|
|
403
|
+
// An ALPHANUMERIC sentinel, not the markdown itself. The core
|
|
404
|
+
// engine escapes markdown punctuation in text content, so a
|
|
405
|
+
// hook returning "**Approval**" would emit "\*\*Approval\*\*".
|
|
406
|
+
// The sentinel has nothing to escape; the real markdown is
|
|
407
|
+
// substituted into the finished string instead.
|
|
408
|
+
var token = "RTENODETYPETOKEN" + i + "ENDRTENODETYPETOKEN";
|
|
409
|
+
var holder = doc.createElement(found[i].type.inline ? "span" : "p");
|
|
410
|
+
holder.textContent = token;
|
|
411
|
+
el.parentNode.insertBefore(holder, el);
|
|
412
|
+
el.parentNode.removeChild(el);
|
|
413
|
+
swaps.push({ holder: holder, original: el });
|
|
414
|
+
tokens.push({ token: token, markdown: String(md) });
|
|
415
|
+
}
|
|
416
|
+
var out = originalToMarkdown.call(editor);
|
|
417
|
+
for (var k = 0; k < tokens.length; k++) {
|
|
418
|
+
out = out.split(tokens[k].token).join(tokens[k].markdown);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
} finally {
|
|
422
|
+
for (var j = swaps.length - 1; j >= 0; j--) {
|
|
423
|
+
var s = swaps[j];
|
|
424
|
+
if (s.holder.parentNode) {
|
|
425
|
+
s.holder.parentNode.insertBefore(s.original, s.holder);
|
|
426
|
+
s.holder.parentNode.removeChild(s.holder);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (savedRange) {
|
|
430
|
+
try {
|
|
431
|
+
var sel2 = doc.getSelection();
|
|
432
|
+
sel2.removeAllRanges();
|
|
433
|
+
sel2.addRange(savedRange);
|
|
434
|
+
} catch (e) { /* selection may no longer be placeable */ }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
if (!window.RTE_DefaultConfig) window.RTE_DefaultConfig = {};
|
|
2
|
+
|
|
3
|
+
RTE_DefaultConfig.plugin_rubyannotation = RTE_Plugin_RubyAnnotation;
|
|
4
|
+
|
|
5
|
+
// 2026-09-04 Ruby annotation (phonetic guides above base text).
|
|
6
|
+
//
|
|
7
|
+
// Furigana in Japanese, pinyin/bopomofo in Chinese, ruby glosses in academic
|
|
8
|
+
// typesetting. Word has it, Lexical ships a RubyExtension, and it was the one
|
|
9
|
+
// item in the Lexical feature gap register worth scheduling: small, self
|
|
10
|
+
// contained, and an i18n/accessibility feature rather than a novelty.
|
|
11
|
+
//
|
|
12
|
+
// The markup is standard HTML, not an invention:
|
|
13
|
+
//
|
|
14
|
+
// <ruby>漢字<rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby>
|
|
15
|
+
//
|
|
16
|
+
// The <rp> parentheses are the accessibility half and are why they are emitted
|
|
17
|
+
// by default: a browser or assistive technology with no ruby support falls back
|
|
18
|
+
// to rendering "漢字(かんじ)" inline instead of running the reading straight
|
|
19
|
+
// into the base text as "漢字かんじ". Copy-paste to a plain-text target gets the
|
|
20
|
+
// same readable fallback.
|
|
21
|
+
//
|
|
22
|
+
// ruby/rt/rp are ALREADY in the sanitizer allowlist (checked, not assumed), so
|
|
23
|
+
// the markup survives sanitisation with no allowlist change.
|
|
24
|
+
function RTE_Plugin_RubyAnnotation() {
|
|
25
|
+
var obj = this;
|
|
26
|
+
var config;
|
|
27
|
+
var editor;
|
|
28
|
+
|
|
29
|
+
obj.PluginName = "RubyAnnotation";
|
|
30
|
+
|
|
31
|
+
obj.InitConfig = function (argconfig) {
|
|
32
|
+
config = argconfig;
|
|
33
|
+
if (config.rubyAnnotationEnabled === false) return;
|
|
34
|
+
|
|
35
|
+
if (typeof config.rubyAnnotationParentheses !== "boolean") config.rubyAnnotationParentheses = true;
|
|
36
|
+
|
|
37
|
+
// BOTH toolbars. config.toolbar defaults to "default", which resolves to
|
|
38
|
+
// toolbar_default, so appending only to toolbar_full would leave the
|
|
39
|
+
// button unreachable for every host that never configured a toolbar -
|
|
40
|
+
// the mistake that shipped inlinecode invisible for a whole release.
|
|
41
|
+
// The default toolbar gets it in the overflow set, where a niche
|
|
42
|
+
// typographic feature belongs; toolbar_full gets it inline.
|
|
43
|
+
appendToolbarCommand("toolbar_full", "#{rubyannotation}");
|
|
44
|
+
appendSubToolbarCommand("subtoolbar_more", "rubyannotation");
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
obj.InitEditor = function (argeditor) {
|
|
48
|
+
editor = argeditor;
|
|
49
|
+
if (config.rubyAnnotationEnabled === false) return;
|
|
50
|
+
|
|
51
|
+
editor.ruby = {
|
|
52
|
+
add: function (reading) { return applyRuby(reading); },
|
|
53
|
+
get: function () { var r = findRubyAtSelection(); return r ? readingOf(r) : null; },
|
|
54
|
+
isRuby: function () { return !!findRubyAtSelection(); },
|
|
55
|
+
open: function () { obj.OpenRubyDialog(); },
|
|
56
|
+
remove: function () { return removeRuby(); }
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
injectStyles();
|
|
60
|
+
|
|
61
|
+
editor.toolbarFactoryMap = editor.toolbarFactoryMap || {};
|
|
62
|
+
editor.toolbarFactoryMap["rubyannotation"] = function (cmd) {
|
|
63
|
+
return editor.createToolbarButton(cmd);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
editor.attachEvent("exec_command_rubyannotation", function (state) {
|
|
67
|
+
state.returnValue = true;
|
|
68
|
+
state.stopBubble = true;
|
|
69
|
+
obj.OpenRubyDialog();
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function appendToolbarCommand(toolbar, item) {
|
|
74
|
+
if (!config[toolbar]) return;
|
|
75
|
+
if (config[toolbar].indexOf(item) !== -1) return;
|
|
76
|
+
config[toolbar] = config[toolbar] + item;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// subtoolbar_* entries are a braced, comma-separated list rather than the
|
|
80
|
+
// "#{name}" form the main toolbars use.
|
|
81
|
+
function appendSubToolbarCommand(toolbar, name) {
|
|
82
|
+
var val = config[toolbar];
|
|
83
|
+
if (!val || val.indexOf(name) !== -1) return;
|
|
84
|
+
var i = val.indexOf("}");
|
|
85
|
+
if (i === -1) return;
|
|
86
|
+
config[toolbar] = val.substring(0, i) + "," + name + val.substring(i);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ruby has no default styling worth relying on: browsers vary in whether the
|
|
90
|
+
// reading is sized down at all, and <rp> must be hidden wherever ruby DOES
|
|
91
|
+
// render or the parentheses show up twice.
|
|
92
|
+
function injectStyles() {
|
|
93
|
+
var hostDoc = config.container.ownerDocument;
|
|
94
|
+
if (hostDoc.getElementById("rte-ruby-style")) return;
|
|
95
|
+
var style = hostDoc.createElement("style");
|
|
96
|
+
style.id = "rte-ruby-style";
|
|
97
|
+
style.innerHTML = [
|
|
98
|
+
".rte-ruby-annotated{ruby-position:over;}",
|
|
99
|
+
".rte-ruby-annotated>rt{font-size:.55em;line-height:1.15;opacity:.85;user-select:none;}",
|
|
100
|
+
"@supports (display:ruby){.rte-ruby-annotated>rp{display:none;}}"
|
|
101
|
+
].join("");
|
|
102
|
+
hostDoc.head.appendChild(style);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function editableRoot() {
|
|
106
|
+
return editor && editor.getEditable ? editor.getEditable() : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function currentRange() {
|
|
110
|
+
var doc = editor.getDocument();
|
|
111
|
+
try {
|
|
112
|
+
var sel = doc.getSelection();
|
|
113
|
+
if (!sel || !sel.rangeCount) return null;
|
|
114
|
+
return sel.getRangeAt(0);
|
|
115
|
+
} catch (e) { return null; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function findRubyAtSelection() {
|
|
119
|
+
var range = currentRange();
|
|
120
|
+
var root = editableRoot();
|
|
121
|
+
if (!range || !root) return null;
|
|
122
|
+
var node = range.startContainer;
|
|
123
|
+
while (node && node !== root) {
|
|
124
|
+
if (node.nodeType === 1 && node.nodeName === "RUBY") return node;
|
|
125
|
+
node = node.parentNode;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readingOf(rubyEl) {
|
|
131
|
+
var rt = rubyEl.querySelector("rt");
|
|
132
|
+
return rt ? (rt.textContent || "") : "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function baseOf(rubyEl) {
|
|
136
|
+
var out = "";
|
|
137
|
+
for (var i = 0; i < rubyEl.childNodes.length; i++) {
|
|
138
|
+
var n = rubyEl.childNodes[i];
|
|
139
|
+
if (n.nodeType === 3) out += n.nodeValue;
|
|
140
|
+
else if (n.nodeType === 1 && n.nodeName !== "RT" && n.nodeName !== "RP") out += n.textContent || "";
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function esc(s) {
|
|
146
|
+
return String(s == null ? "" : s)
|
|
147
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
148
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildRuby(base, reading) {
|
|
152
|
+
var parens = config.rubyAnnotationParentheses;
|
|
153
|
+
return '<ruby class="rte-ruby-annotated">' + esc(base) +
|
|
154
|
+
(parens ? "<rp>(</rp>" : "") +
|
|
155
|
+
"<rt>" + esc(reading) + "</rt>" +
|
|
156
|
+
(parens ? "<rp>)</rp>" : "") +
|
|
157
|
+
"</ruby>";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function applyRuby(reading) {
|
|
161
|
+
var existing = findRubyAtSelection();
|
|
162
|
+
if (existing) {
|
|
163
|
+
var rt = existing.querySelector("rt");
|
|
164
|
+
if (!rt) {
|
|
165
|
+
rt = existing.ownerDocument.createElement("rt");
|
|
166
|
+
existing.appendChild(rt);
|
|
167
|
+
}
|
|
168
|
+
rt.textContent = String(reading == null ? "" : reading);
|
|
169
|
+
editor.focus();
|
|
170
|
+
return existing;
|
|
171
|
+
}
|
|
172
|
+
var range = currentRange();
|
|
173
|
+
if (!range || range.collapsed) return null;
|
|
174
|
+
var base = String(range.toString() || "");
|
|
175
|
+
if (!base) return null;
|
|
176
|
+
editor.insertHTML(buildRuby(base, reading));
|
|
177
|
+
editor.focus();
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Unwrap back to the base text, discarding the reading and the <rp> fallback
|
|
182
|
+
// parentheses. Without dropping <rp> the text would read "漢字()".
|
|
183
|
+
function removeRuby() {
|
|
184
|
+
var rubyEl = findRubyAtSelection();
|
|
185
|
+
if (!rubyEl) return false;
|
|
186
|
+
var doc = rubyEl.ownerDocument;
|
|
187
|
+
var text = doc.createTextNode(baseOf(rubyEl));
|
|
188
|
+
rubyEl.parentNode.insertBefore(text, rubyEl);
|
|
189
|
+
rubyEl.parentNode.removeChild(rubyEl);
|
|
190
|
+
editor.focus();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function append(parent, tag, cssText, className) {
|
|
195
|
+
var el = parent.ownerDocument.createElement(tag);
|
|
196
|
+
if (cssText) el.style.cssText = cssText;
|
|
197
|
+
if (className) el.className = className;
|
|
198
|
+
parent.appendChild(el);
|
|
199
|
+
return el;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
obj.OpenRubyDialog = function () {
|
|
203
|
+
var existing = findRubyAtSelection();
|
|
204
|
+
var range = currentRange();
|
|
205
|
+
var baseText = existing ? baseOf(existing) : (range ? String(range.toString() || "") : "");
|
|
206
|
+
|
|
207
|
+
var dlg = editor.createDialog(
|
|
208
|
+
(editor.getLangText && editor.getLangText("rubyannotationtitle")) || "Ruby annotation",
|
|
209
|
+
"rte-dialog-ruby"
|
|
210
|
+
);
|
|
211
|
+
var close = typeof dlg.close === "function" ? function () { dlg.close(); } : function () { editor.closeCurrentPopup(); };
|
|
212
|
+
|
|
213
|
+
var wrap = append(dlg, "div", "padding:14px;min-width:340px;font:13px -apple-system,Segoe UI,sans-serif");
|
|
214
|
+
|
|
215
|
+
append(wrap, "div", "font-weight:600;margin-bottom:4px").innerText = "Base text";
|
|
216
|
+
var baseView = append(wrap, "div",
|
|
217
|
+
"padding:8px 10px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;min-height:20px;word-break:break-word");
|
|
218
|
+
baseView.textContent = baseText;
|
|
219
|
+
|
|
220
|
+
append(wrap, "div", "font-weight:600;margin:10px 0 4px").innerText = "Reading";
|
|
221
|
+
var input = append(wrap, "input",
|
|
222
|
+
"width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid #cbd5e1;border-radius:8px;font:13px inherit");
|
|
223
|
+
input.type = "text";
|
|
224
|
+
input.value = existing ? readingOf(existing) : "";
|
|
225
|
+
input.placeholder = "e.g. かんじ";
|
|
226
|
+
|
|
227
|
+
var note = append(wrap, "div", "font-size:11px;color:#94a3b8;margin-top:6px");
|
|
228
|
+
note.innerText = baseText
|
|
229
|
+
? "Shown above the base text. Parentheses are emitted for readers without ruby support."
|
|
230
|
+
: "Select the text to annotate first, then reopen this dialog.";
|
|
231
|
+
|
|
232
|
+
var footer = append(wrap, "div", "display:flex;justify-content:space-between;gap:8px;margin-top:14px");
|
|
233
|
+
var left = append(footer, "div", "display:flex;gap:8px");
|
|
234
|
+
if (existing) {
|
|
235
|
+
var rm = append(left, "button", "padding:6px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#b91c1c;cursor:pointer");
|
|
236
|
+
rm.type = "button"; rm.textContent = "Remove";
|
|
237
|
+
rm.onclick = function () { removeRuby(); close(); };
|
|
238
|
+
}
|
|
239
|
+
var right = append(footer, "div", "display:flex;gap:8px");
|
|
240
|
+
var cancel = append(right, "button", "padding:6px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;cursor:pointer");
|
|
241
|
+
cancel.type = "button"; cancel.textContent = "Cancel"; cancel.onclick = close;
|
|
242
|
+
var ok = append(right, "button", "padding:6px 14px;border:1px solid #1d67ba;border-radius:8px;background:#1d67ba;color:#fff;cursor:pointer");
|
|
243
|
+
ok.type = "button"; ok.textContent = existing ? "Update" : "Add";
|
|
244
|
+
ok.disabled = !baseText;
|
|
245
|
+
|
|
246
|
+
ok.onclick = function () {
|
|
247
|
+
var reading = input.value.trim();
|
|
248
|
+
if (!reading || !baseText) { close(); return; }
|
|
249
|
+
applyRuby(reading);
|
|
250
|
+
close();
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
setTimeout(function () { input.focus(); }, 0);
|
|
254
|
+
};
|
|
255
|
+
}
|