From f2960fba94ba0d4c90ec84691eeb189f54408f16 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Wed, 10 Sep 2025 16:25:04 +0200 Subject: [PATCH 01/26] draft shortcode integration into tinymce --- integreat_cms/static/src/editor_content.ts | 1 + .../static/src/js/forms/tinymce-init.ts | 3 +- .../js/tinymce-plugins/shortcodes/plugin.js | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js diff --git a/integreat_cms/static/src/editor_content.ts b/integreat_cms/static/src/editor_content.ts index 1c6bc44602..db6d286096 100644 --- a/integreat_cms/static/src/editor_content.ts +++ b/integreat_cms/static/src/editor_content.ts @@ -8,6 +8,7 @@ import "./js/tinymce-plugins/autolink_tel/plugin.js"; import "./js/tinymce-plugins/custom_link_input/plugin.js"; import "./js/tinymce-plugins/custom_contact_input/plugin.js"; import "./js/tinymce-plugins/mediacenter/plugin.js"; +import "./js/tinymce-plugins/shortcodes/plugin.js"; /* Custom tinymce content css */ import "./css/tinymce_custom.css"; diff --git a/integreat_cms/static/src/js/forms/tinymce-init.ts b/integreat_cms/static/src/js/forms/tinymce-init.ts index 2d59c1ea9e..a0caade919 100644 --- a/integreat_cms/static/src/js/forms/tinymce-init.ts +++ b/integreat_cms/static/src/js/forms/tinymce-init.ts @@ -99,7 +99,7 @@ window.addEventListener("load", () => { }, insert: { title: "Insert", - items: "openmediacenter add_link add_contact media | charmap hr", + items: "add_shortcode | openmediacenter add_link add_contact media | charmap hr", }, }, link_title: false, @@ -110,6 +110,7 @@ window.addEventListener("load", () => { mediacenter: tinymceConfig.getAttribute("data-custom-plugins"), custom_link_input: tinymceConfig.getAttribute("data-custom-plugins"), custom_contact_input: tinymceConfig.getAttribute("data-custom-plugins"), + shortcodes: tinymceConfig.getAttribute("data-custom-plugins"), }, link_default_protocol: "https", link_target_list: false, diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js new file mode 100644 index 0000000000..129668d6e3 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js @@ -0,0 +1,33 @@ +(() => { + const tinymceConfig = document.getElementById("tinymce-config-options"); + + tinymce.PluginManager.add("shortcodes", (editor, _url) => { + function insertShortcode() { + let html = `[shortcode 2]`; + editor.insertContent(html); + } + + editor.on('BeforeSetContent', function(e) { + // Ensure all shortcodes are represented by a marker node in tinyMCE + e.content = e.content.replace(/(\[shortcode (\d+)\]<\/span>|\[shortcode (\d+)\])/g, (match, _, a, b) => { + return `[shortcode ${a || b}]`; + }); + }); + + editor.on('PostProcess', function(e) { + // Strip the mce marker out when extracting the content for saving or the source code view + e.content = e.content.replace(/([^<]+)<\/span>/g, '$1'); + }); + + editor.ui.registry.addMenuItem("add_shortcode", { + text: "Shortcode", + icon: "link", + //shortcut: "Meta+L", + onAction: insertShortcode, + }); + + return {}; + }); +})(); + + From c4bedf01f5e882ba86190882d8f2738b73b04196 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 15 Sep 2025 09:28:35 +0200 Subject: [PATCH 02/26] WIP --- .../static/src/js/forms/tinymce-init.ts | 2 +- .../js/tinymce-plugins/shortcodes/contact.js | 13 + .../src/js/tinymce-plugins/shortcodes/page.js | 19 + .../js/tinymce-plugins/shortcodes/plugin.js | 34 +- .../tinymce-plugins/shortcodes/shortcodes.ts | 395 ++ .../js/tinymce-plugins/shortcodes/utils.ts | 375 ++ .../src/js/tinymce-plugins/tinymce.d.ts | 3863 +++++++++++++++++ 7 files changed, 4696 insertions(+), 5 deletions(-) create mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js create mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.js create mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts create mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts create mode 100644 integreat_cms/static/src/js/tinymce-plugins/tinymce.d.ts diff --git a/integreat_cms/static/src/js/forms/tinymce-init.ts b/integreat_cms/static/src/js/forms/tinymce-init.ts index a0caade919..9dbbf4ba65 100644 --- a/integreat_cms/static/src/js/forms/tinymce-init.ts +++ b/integreat_cms/static/src/js/forms/tinymce-init.ts @@ -99,7 +99,7 @@ window.addEventListener("load", () => { }, insert: { title: "Insert", - items: "add_shortcode | openmediacenter add_link add_contact media | charmap hr", + items: "add_shortcode_page | add_shortcode_contact | openmediacenter add_link add_contact media | charmap hr", }, }, link_title: false, diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js new file mode 100644 index 0000000000..d45c95a0e4 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js @@ -0,0 +1,13 @@ +import { ShortcodeHandle } from "./utils"; + +class ContactHandle extends ShortcodeHandle { + keyword = "contact"; + addIcon = "contact"; + editIcon = "contact"; + removeIcon = "remove"; + + t() {} +} + + +export default ContactHandle; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.js new file mode 100644 index 0000000000..072cae9ba5 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.js @@ -0,0 +1,19 @@ +import { ShortcodeHandle } from "./utils"; + +/* + +- canonical representation (normalized shortcode) +- rendered preview +- dialog system to edit + +*/ + + +class PageHandle extends ShortcodeHandle { + keyword = "page"; + + t() {} +} + + +export default PageHandle; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js index 129668d6e3..ca52733adc 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js @@ -1,33 +1,59 @@ +import { Parser } from "./shortcodes"; +import ContactHandle from "./contact"; +import PageHandle from "./page"; +import { Registry } from "./utils"; + +Registry.register(new PageHandle()); +Registry.register(new ContactHandle()); + + (() => { const tinymceConfig = document.getElementById("tinymce-config-options"); + const parser = new Parser("[", "]", "\\", true); + const context = { + language: tinymceConfig.getAttribute("data-language"), + directionality: tinymceConfig.getAttribute("data-directionality"), + }; - tinymce.PluginManager.add("shortcodes", (editor, _url) => { + tinymce.PluginManager.add("shortcodes", editor => { + /* function insertShortcode() { let html = `[shortcode 2]`; editor.insertContent(html); } + */ editor.on('BeforeSetContent', function(e) { + /* // Ensure all shortcodes are represented by a marker node in tinyMCE e.content = e.content.replace(/(\[shortcode (\d+)\]<\/span>|\[shortcode (\d+)\])/g, (match, _, a, b) => { return `[shortcode ${a || b}]`; }); + */ + console.log("Parsing registered handles:", Registry.instance.handles); + try { + e.content = parser.parse(e.content, context); + } catch (e) { + console.error("Failed to expand shortcodes:", e); + } }); editor.on('PostProcess', function(e) { // Strip the mce marker out when extracting the content for saving or the source code view - e.content = e.content.replace(/([^<]+)<\/span>/g, '$1'); + e.content = e.content.replace(/]*)>([^<]+)<\/span>/g, '$2'); }); + /* editor.ui.registry.addMenuItem("add_shortcode", { text: "Shortcode", icon: "link", //shortcut: "Meta+L", onAction: insertShortcode, }); + */ + + Registry.setupAll(editor, parser); return {}; }); })(); - - diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts new file mode 100644 index 0000000000..e6b2afefe6 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts @@ -0,0 +1,395 @@ +/******************************************* + * JS version of pythons shortcode package * + * which is licensed under MIT * + *******************************************/ + + +// Globally-registered handler functions indexed by keyword. +const global_keywords = new Map, context: any, content?: string) => string, string]>(); + + +// The set of all end-words for globally-registered block-scoped shortcodes. +const global_endwords = new Set(); + + +// Decorator function for globally registering shortcode handlers. +function register(keyword: string, endword: string) { + + function register_function(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string) { + global_keywords.set(keyword, [func, endword]); + if (endword) { + global_endwords.add(endword); + } + return func; + } + + return register_function; +} + + +/*********************** + * Exception Classes * + ***********************/ + + +// Base class for all exceptions raised by the library. +class ShortcodeError extends Error { + constructor(message: string) { + super(message); + this.name = "ShortcodeError"; + } +} + + +// Raised if the parser detects invalid shortcode syntax. +class ShortcodeSyntaxError extends ShortcodeError { + constructor(message: string) { + super(message); + this.name = "ShortcodeSyntaxError"; + } +} + + +// Raised if a handler function throws an error. +class ShortcodeRenderingError extends ShortcodeError { + constructor(message: string) { + super(message); + this.name = "ShortcodeRenderingError"; + } +} + + +/*************** + * AST Nodes * + ***************/ + + +// Input text is parsed into a tree of ASTNode instances. +class ASTNode { + children: ASTNode[] = []; + token: Token; + + constructor() { + } + + render(context: any): string { + return this.children.map(c => c.render(context)).join(""); + } +} + + +// Represents ordinary text not enclosed in tag delimiters. +class Text extends ASTNode { + text: string; + + constructor(text: string) { + super(); + this.text = text; + } + + render(context: any): string { + return this.text; + } +} + + +// Base class for atomic and block-scoped shortcodes. +class Shortcode extends ASTNode { + // Regex for parsing the shortcode's arguments. + re_args = new RegExp(` + (?:([^\s'"=]+)=)? + ( + "((?:[^\\"]|\\.)*)" + | + '((?:[^\\']|\\.)*)' + ) + | + ([^\s'"=]+)=(\S+) + | + (\S+) + `, "g"); + + handler: (pargs: string[], kwargs: Map, context: any, content?: string) => string; + pargs: string[]; + kwargs: Map; + children: ASTNode[]; + + constructor(token: Token, handler_function: (pargs: string[], kwargs: Map, context: any, content?: string) => string) { + super(); + this.token = token; + this.handler = handler_function; + [this.pargs, this.kwargs] = this.parse_args(token.text.slice(token.keyword.length)); + this.children = []; + } + + parse_args(argstring: string): [string[], Map] { + const pargs: string[] = []; + const kwargs = new Map(); + for (const match of argstring.matchAll(this.re_args)) { + if (match.groups[2] || match.groups[5]) { + const key = match.groups[1] || match.groups[5]; + const value = match.groups[3] || match.groups[4] || match.groups[6]; + if (key) { + kwargs.set(key, value); + } else { + pargs.push(value); + } + } else { + pargs.push(match.groups[7]); + } + } + return [pargs, kwargs]; + } +} + + +// An atomic shortcode is a shortcode with no closing tag. +class AtomicShortcode extends Shortcode { + /* If the shortcode handler raises an exception we intercept it and wrap it + * in a ShortcodeRenderingError. + */ + render(context: any) { + try { + return this.handler(this.pargs, this.kwargs, context).toString(); + } catch (ex: unknown) { + const msg = `An exception was raised while rendering the '${this.token.keyword}' shortcode in line ${this.token.line_number}.`; + const error = new ShortcodeRenderingError(msg); + if (ex instanceof Error) { + error.stack = ex.stack; + } + throw error; + } + } +} + + +// A block-scoped shortcode is a shortcode with a closing tag. +class BlockShortcode extends Shortcode { + /* If the shortcode handler raises an exception we intercept it and wrap it + * in a ShortcodeRenderingError. The original exception will still be + * available via the exception's __cause__ attribute. + */ + render(context: any) { + const content = this.children.map(c => c.render(context)).join(""); + try { + return this.handler(this.pargs, this.kwargs, context, content).toString(); + } catch (ex: unknown) { + const msg = `An exception was raised while rendering the '${this.token.keyword}' shortcode in line ${this.token.line_number}.` + const error = new ShortcodeRenderingError(msg); + if (ex instanceof Error) { + error.stack = ex.stack; + } + throw error; + } + } +} + + +/************ + * Parser * + ************/ + + +/* A Parser instance parses input text and renders shortcodes. A single Parser + * instance can parse an unlimited number of input strings. Note that the parse() + * method accepts an optional arbitrary context object which it passes on to each + * shortcode's handler function. + * + * If the `inherit_globals` parameter is true, the parser will inherit a copy of + * the set of globally-registered shortcodes at the moment of instantiation. + * + * If `ignore_unknown` is true, unknown shortcodes are ignored. If this parameter + * is false (the default), unknown shortcodes cause an error. + */ +class Parser { + start: string; + end: string; + esc_start: string; + keywords: Map, context: any, content?: string) => string, string]>; + endwords: Set; + ignore_unknown: boolean; + + constructor(start: string = '[%', end: string = '%]', esc: string = '\\', inherit_globals: boolean = true, ignore_unknown: boolean = false) { + this.start = start; + this.end = end; + this.esc_start = esc + start; + this.keywords = new Map, context: any, content?: string) => string, string]>(inherit_globals ? global_keywords : null); + this.endwords = new Set(inherit_globals ? global_endwords : null); + this.ignore_unknown = ignore_unknown; + } + + register(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string, keyword: string, endword: string = null) { + this.keywords.set(keyword, [func, endword]); + if (endword) { + this.endwords.add(endword); + } + } + + parse(text: string, context: any = null) { + if (!text.includes(this.start)) { + return text; + } + + const stack = [new ASTNode()]; + const expecting = []; + + const lexer = new Lexer(text, this.start, this.end, this.esc_start); + for (const token of lexer.tokenize()) { + if (token.type == "TEXT") { + stack[stack.length-1].children.push(new Text(token.text)); + } else if (this.keywords.has(token.keyword)) { + const [handler, endword] = this.keywords.get(token.keyword); + if (endword) { + const node = new BlockShortcode(token, handler); + stack[stack.length-1].children.push(node); + stack.push(node); + expecting.push(endword); + } else { + const node = new AtomicShortcode(token, handler); + stack[stack.length-1].children.push(node); + } + } else if (this.endwords.has(token.keyword)) { + if (expecting.length == 0) { + const msg = `Unexpected '${token.keyword}' tag in line ${token.line_number}.`; + throw new ShortcodeSyntaxError(msg); + } else if (token.keyword == expecting[expecting.length-1]) { + stack.pop(); + expecting.pop(); + } else { + const msg = `Unexpected '${token.keyword}' tag in line ${token.line_number}. The shortcode parser was expecting a closing '${expecting[-1]}' tag.`; + throw new ShortcodeSyntaxError(msg); + } + } else if (token.keyword == '') { + const msg = `Empty shortcode tag in line ${token.line_number}.`; + throw new ShortcodeSyntaxError(msg); + } else if (this.ignore_unknown) { + stack[stack.length-1].children.push(new Text(token.raw_text)); + } else { + const msg = `Unrecognised shortcode tag '${token.keyword}' in line ${token.line_number}.` + throw new ShortcodeSyntaxError(msg); + } + } + + if (expecting.length) { + const token = stack[stack.length-1].token; + const msg = `Unexpected end of document. The shortcode parser was expecting a closing '${expecting[-1]}' tag to close the '${token.keyword}' tag opened in line ${token.line_number}.`; + throw new ShortcodeSyntaxError(msg); + } + + return stack.pop().render(context); + } +} + + +/*********** + * Lexer * + ***********/ + + +class Token { + keyword: string; + type: string; + text: string; + raw_text: string; + line_number: number; + + constructor(token_type: string, token_text: string, raw_text: string, line_number: number) { + const words = token_text.split(/\s+/); + this.keyword = words ? words[0] : ''; + this.type = token_type; + this.text = token_text; + this.raw_text = raw_text; + this.line_number = line_number; + } + + toString(): string { + return `(${this.type}, ${this.text.toString()}, ${this.line_number})`; + } +} + + +class Lexer { + text: string; + start: string; + end: string; + esc_start: string; + tokens: Token[]; + index: number; + line_number: number; + + constructor(text: string, start: string, end: string, esc_start: string) { + this.text = text; + this.start = start; + this.end = end; + this.esc_start = esc_start; + this.tokens = []; + this.index = 0; + this.line_number = 1; + } + + match(target: string): boolean { + if (this.text.startsWith(target, this.index)) { + return true; + } + return false; + } + + advance() { + if (this.text[this.index] == '\n') { + this.line_number += 1; + } + this.index += 1; + } + + tokenize(): Token[] { + while (this.index < this.text.length) { + if (this.match(this.esc_start)) { + this.read_escaped_tag_delimiter(); + } else if (this.match(this.start)) { + this.read_tag(); + } else { + this.read_text(); + } + } + return this.tokens; + } + + read_escaped_tag_delimiter() { + this.index += this.esc_start.length; + this.tokens.push(new Token("TEXT", this.start, this.esc_start, this.line_number)); + } + + read_tag() { + this.index += this.start.length; + const start_index = this.index; + const start_line_number = this.line_number; + while (this.index < this.text.length) { + if (this.match(this.end)) { + const text = this.text.slice(start_index, this.index).trim(); + const raw_text = this.text.slice(start_index-this.start.length, this.index+this.end.length); + this.tokens.push(new Token("TAG", text, raw_text, start_line_number)); + this.index += this.end.length; + return; + } + this.advance(); + } + const msg = `Unclosed shortcode tag. The tag was opened in line ${start_line_number}.`; + throw new ShortcodeSyntaxError(msg); + } + + read_text() { + const start_index = this.index; + const start_line_number = this.line_number; + while (this.index < this.text.length) { + if (this.match(this.esc_start) || this.match(this.start)) { + break; + } + this.advance(); + } + const text = this.text.slice(start_index, this.index); + this.tokens.push(new Token("TEXT", text, text, start_line_number)); + } +} + + +export { register, ShortcodeError, ShortcodeSyntaxError, ShortcodeRenderingError, ASTNode, Text, Shortcode, AtomicShortcode, BlockShortcode, Parser, Token, Lexer }; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts new file mode 100644 index 0000000000..b14e9678e6 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -0,0 +1,375 @@ +/// +import type { ContextFormInstanceApi, ContextFormButtonInstanceApi, ContextFormToggleButtonInstanceApi, DialogInstanceApi, DialogData, MenuItemInstanceApi, DialogSpec, BodyComponentSpec } from "../tinymce.d.ts"; +import { Editor } from "tinymce"; + +import type { Parser } from "./shortcodes"; +import { Shortcode as parser } from "./shortcodes"; + +/* + +- enable easily implementing behaviour to edit/manage different kinds of shortcodes +- register handle() function to parse shortcode arguments into tinymce readable marker +- ??? provide function to get canocical shortcode ??? (to be used when turning markers back into just the shortcode) +- provide easily overrideable methods for UI handling + +- one ShortcodeHandle object per shortcode type, not per shortcode in content + +*/ + + +type TextDescriptor = string | ((self: ShortcodeHandle) => string); +/* Positional arguments: + * - number: How many positional arguments have to be given (exactly, not more and not less) + * - [number, number]: Up to how many positional arguments CAN be given, and how many of those are required + * - null: Allow any number of positional arguments + * Keyword arguments: + * - (string | [string, boolean])[]: List of all keyword arguments being accepted. + * If an item is given as a list where the second value is true, the keyword is required. + * Also serves as a canonical order normalizing the shortcode + * - null: Allow any keyword argument + */ +type PargsConstraint = number | [number, number] | null; +type KWargsDescriptor = (string | [string, boolean])[] | null; + + +class ShortcodeHandle { + keyword: string; + endword: string | null = null; + editor: Editor; + tinymceConfig: HTMLElement; + addText: TextDescriptor = (self: ShortcodeHandle) => `Insert ${self.keyword}`; + addIcon: string = "link"; + editText: TextDescriptor = (self: ShortcodeHandle) => `Edit ${self.keyword}`; + editIcon: string = "link"; + removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; + removeIcon: string = "unlink"; + + static escape() { + return (str: string): string => str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; + } + + pargs: PargsConstraint = null; + kwargs: KWargsDescriptor = null; + get maxPargs() { + return this.pargs === null ? Infinity : (typeof this.pargs === "number" ? this.pargs : this.pargs[0]); + } + get minPargs() { + return this.pargs === null ? 0 : (typeof this.pargs === "number" ? this.pargs : this.pargs[1]); + } + + text(text: TextDescriptor): string { + if (typeof text === "string") { + return text; + } + return text(this); + } + + predicate(node: Element): boolean { + if (!(node instanceof HTMLElement)) return false; + return "shortcode" in node.dataset && node.dataset.shortcode == this.keyword; + } + + getNode(): HTMLElement | null { + const node = this.editor.selection.getNode(); + return this.predicate(node) ? node : null; + }; + + sortKWargs(kwpairs: [string, string][]): [string, string][] { + const order = this.kwargs !== null ? this.kwargs.map(kw => typeof kw === "string" ? kw : kw[0]) : []; + return kwpairs.sort((a, b) => { + const aPos = order.includes(a[0]) ? order.indexOf(a[0]) : order.length; + const bPos = order.includes(b[0]) ? order.indexOf(b[0]) : order.length; + return aPos - bPos; + }); + } + + renderShortcode(pargs: string[], kwargs: Map): string { + const pairs = this.sortKWargs(Object.entries(kwargs)).map(pair => pair.map(escape).join("=")); + const parts = [escape(this.keyword), ...pargs.map(escape), ...pairs]; + return `[${parts.join(" ")}]`; + } + + renderPreview(pargs: string[], kwargs: Map): string { + const ppairs = pargs.map((arg, i) => `data-parg${i}="${arg}"`); + const kwpairs = this.sortKWargs(Object.entries(kwargs)).map(([key, value]) => `data-kw-${key}=${escape(value)}`); + const parts = [`class="mceNonEditable"`, `data-shortcode="${this.keyword}"`, ...ppairs, ...kwpairs]; + return `${this.renderShortcode(pargs, kwargs)}`; + } + + openEditDialog(formApi: MenuItemInstanceApi | ContextFormInstanceApi) { + const node = this.getNode(); + + const initialPargs = node !== null ? node.dataset.pargs.split(" ") : []; + while (initialPargs.length < this.minPargs) { + initialPargs.push(""); + } + + const prefix = "data-kw-"; + const initialKWargs = this.sortKWargs(Object.entries(node !== null ? node.dataset : {}).reduce((acc, pair) => { + if (pair[0].startsWith(prefix)) { + const keyword = pair[0].slice(prefix.length); + acc.push([keyword, pair[1]]); + } + return acc; + }, [])); + + initialPargs.push("8"); + initialKWargs.push(["test", "3"]) + + const argumentItems: BodyComponentSpec[] = []; + initialPargs.forEach((parg: string, i: number) => { + argumentItems.push({ + type: "input", + name: `parg${i}`, + label: `Argument ${i}`, + }); + }); + if (this.minPargs != this.maxPargs) { + argumentItems.push({ + type: "bar", + items: [ + { + type: "button", + text: "–", + name: "parg-remove", + }, + { + type: "button", + text: "+", + name: "parg-add", + }, + ], + }); + } + initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { + if (this.kwargs === null) { + argumentItems.push({ + type: "bar", + items: [ + { + type: "input", + name: `kwarg${i}-name`, + label: `Keyword argument ${i}`, + }, + { + type: "input", + name: `kwarg${i}-value`, + label: `Value`, + }, + ], + }); + } else { + argumentItems.push({ + type: "input", + name: `kw-${keyword}`, + label: `${keyword.slice(0,1).toUpperCase()}${keyword.slice(1).replace("-", " ")}`, + }); + } + }); + if (this.kwargs === null) { + argumentItems.push({ + type: "bar", + items: [ + { + type: "button", + text: "–", + name: "kwarg-remove", + }, + { + type: "button", + text: "+", + name: "kwarg-add", + }, + ], + }); + } + + const dialogConfig: DialogSpec = { + title: this.text(this.editText), + body: { + type: "panel", + items: argumentItems, + }, + buttons: [ + { + type: "cancel", + text: this.tinymceConfig.getAttribute("data-dialog-cancel-text"), + }, + { + type: "submit", + name: "submit", + text: this.tinymceConfig.getAttribute("data-dialog-submit-text"), + primary: true, + }, + ], + initialData: { + ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), + ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { + if (this.kwargs === null) { + return acc.concat([ + [`kwarg${i}-name`, keyword], + [`kwarg${i}-value`, value], + ]); + } else { + return acc.concat([ + [`kw-${keyword}`, value], + ]); + } + }, [])), + }, + onSubmit: (api: DialogInstanceApi) => { + const data = api.getData(); + const pargs = Object.entries(data).reduce((acc: string[], [key, value]) => { + const match = key.match(/^parg([0-9]+)$/); + if (match) { + acc[parseInt(match[1])] = value; + } + return acc; + }, []); + type TemporaryPairs = {[key: number]: [null | string, null | string]}; + type FinalizedPairs = {[key: string]: string}; + const kwargs = Object.entries(data).reduce((acc: TemporaryPairs & FinalizedPairs, [key, value]) => { + // First piece together the names with the values again + const match = key.match(/^(kw-(.+)|kwarg([0-9]+)-(name|value))$/); + if (match) { + if (match[2]) { + acc[match[2]] = value; + } else { + const id = parseInt(match[3]); + const which = match[4] == "name" ? 0 : 1; + if (acc[id] === undefined) acc[id] = [null, null]; + acc[id][which] = value; + if (acc[id][(which+1) % 2] !== null) { + // Pair is complete! + [key, value] = acc[id]; + acc[key] = value; + delete acc[id]; + } + } + } + return acc; + }, {}) as unknown as FinalizedPairs; + + if (pargs.length <= this.minPargs || pargs.length >= this.maxPargs) { + return; + } + api.close(); + + // Either insert a new shortcode or update the existing one + const node = this.getNode(); + if (!node) { + this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + } else { + node.remove(); + this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + } + }, + //onChange: updateDialog, + }; + console.log(`[${this.keyword}]`, this, dialogConfig); + + return this.editor.windowManager.open(dialogConfig); + } + + setup(editor: Editor) { + /* default behavior: + * - menu item to insert shortcode → open dialog + * - context toolbar with edit and delete + */ + this.editor = editor; + this.tinymceConfig = document.getElementById("tinymce-config-options"); + + const closeContextToolbar = () => { + editor.fire("contexttoolbar-hide", { + toolbarKey: `shortcode_${this.keyword}_context_form`, + }); + }; + + editor.ui.registry.addMenuItem(`add_shortcode_${this.keyword}`, { + text: this.text(this.addText), + icon: this.addIcon, + onAction: this.openEditDialog.bind(this), + }); + + // This form opens when a shortcode is currently selected with the cursor + editor.ui.registry.addContextForm(`shortcode_${this.keyword}_context_form`, { + predicate: this.predicate.bind(this), + position: "node", + scope: "node", + commands: [ + { + type: "contextformbutton", + icon: this.editIcon, + text: this.text(this.editText), + tooltip: this.text(this.editText), + primary: true, + onAction: ((formApi: ContextFormInstanceApi, api: ContextFormButtonInstanceApi) => { + this.openEditDialog(formApi); + closeContextToolbar(); + }).bind(this), + }, + { + type: "contextformbutton", + icon: this.removeIcon, + text: this.text(this.removeText), + tooltip: this.text(this.removeText), + primary: false, + onAction: (() => { + const node = this.getNode(); + if (node) { + node.remove(); + } + closeContextToolbar(); + }).bind(this), + }, + ], + }); + } +} + + +class Registry { + static #instance: Registry; + + handles: Map; + + private constructor() { + this.handles = new Map(); + } + + public static get instance(): Registry { + if (!Registry.#instance) { + Registry.#instance = new Registry(); + } + return Registry.#instance; + } + + public static register(handle: ShortcodeHandle) { + if (Registry.instance.handles.has(handle.keyword)) { + throw Error(`Keyword ${handle.keyword} already registered as ${Registry.instance.handles.get(handle.keyword)}`); + } + Registry.instance.handles.set(handle.keyword, handle); + } + public static unregister(handle: ShortcodeHandle | string): ShortcodeHandle { + const keyword = handle instanceof ShortcodeHandle ? handle.keyword : handle; + const old_handle = Registry.instance.handles.get(keyword); + if (handle instanceof ShortcodeHandle && old_handle !== handle) { + throw Error(`Keyword ${keyword} registered as a different handle: ${old_handle}`); + } + Registry.instance.handles.delete(keyword); + return old_handle; + } + public static unregisterAll() { + Registry.instance.handles.clear(); + } + + public static setupAll(editor: Editor, parser: Parser) { + Registry.instance.handles.forEach((value: ShortcodeHandle, key: string) => { + value.setup(editor); + parser.register(value.renderPreview.bind(value), key, value.endword); + }); + } +} + + +export { ShortcodeHandle, Registry }; diff --git a/integreat_cms/static/src/js/tinymce-plugins/tinymce.d.ts b/integreat_cms/static/src/js/tinymce-plugins/tinymce.d.ts new file mode 100644 index 0000000000..facc106ca8 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/tinymce.d.ts @@ -0,0 +1,3863 @@ +interface StringPathBookmark { + start: string; + end?: string; + forward?: boolean; +} +interface RangeBookmark { + rng: Range; + forward?: boolean; +} +interface IdBookmark { + id: string; + keep?: boolean; + forward?: boolean; +} +interface IndexBookmark { + name: string; + index: number; +} +interface PathBookmark { + start: number[]; + end?: number[]; + isFakeCaret?: boolean; + forward?: boolean; +} +type Bookmark = StringPathBookmark | RangeBookmark | IdBookmark | IndexBookmark | PathBookmark; +type NormalizedEvent = E & { + readonly type: string; + readonly target: T; + readonly isDefaultPrevented: () => boolean; + readonly preventDefault: () => void; + readonly isPropagationStopped: () => boolean; + readonly stopPropagation: () => void; + readonly isImmediatePropagationStopped: () => boolean; + readonly stopImmediatePropagation: () => void; +}; +type MappedEvent = K extends keyof T ? T[K] : any; +interface NativeEventMap { + 'beforepaste': Event; + 'blur': FocusEvent; + 'beforeinput': InputEvent; + 'click': MouseEvent; + 'compositionend': Event; + 'compositionstart': Event; + 'compositionupdate': Event; + 'contextmenu': PointerEvent; + 'copy': ClipboardEvent; + 'cut': ClipboardEvent; + 'dblclick': MouseEvent; + 'drag': DragEvent; + 'dragdrop': DragEvent; + 'dragend': DragEvent; + 'draggesture': DragEvent; + 'dragover': DragEvent; + 'dragstart': DragEvent; + 'drop': DragEvent; + 'focus': FocusEvent; + 'focusin': FocusEvent; + 'focusout': FocusEvent; + 'input': InputEvent; + 'keydown': KeyboardEvent; + 'keypress': KeyboardEvent; + 'keyup': KeyboardEvent; + 'mousedown': MouseEvent; + 'mouseenter': MouseEvent; + 'mouseleave': MouseEvent; + 'mousemove': MouseEvent; + 'mouseout': MouseEvent; + 'mouseover': MouseEvent; + 'mouseup': MouseEvent; + 'paste': ClipboardEvent; + 'selectionchange': Event; + 'submit': Event; + 'touchend': TouchEvent; + 'touchmove': TouchEvent; + 'touchstart': TouchEvent; + 'touchcancel': TouchEvent; + 'wheel': WheelEvent; +} +type EditorEvent = NormalizedEvent; +interface EventDispatcherSettings { + scope?: any; + toggleEvent?: (name: string, state: boolean) => void | boolean; + beforeFire?: (args: EditorEvent) => void; +} +interface EventDispatcherConstructor { + readonly prototype: EventDispatcher; + new (settings?: EventDispatcherSettings): EventDispatcher; + isNative: (name: string) => boolean; +} +declare class EventDispatcher { + static isNative(name: string): boolean; + private readonly settings; + private readonly scope; + private readonly toggleEvent; + private bindings; + constructor(settings?: EventDispatcherSettings); + fire>(name: K, args?: U): EditorEvent; + dispatch>(name: K, args?: U): EditorEvent; + on(name: K, callback: false | ((event: EditorEvent>) => void | boolean), prepend?: boolean, extra?: {}): this; + off(name?: K, callback?: (event: EditorEvent>) => void): this; + once(name: K, callback: (event: EditorEvent>) => void, prepend?: boolean): this; + has(name: string): boolean; +} +type UndoLevelType = 'fragmented' | 'complete'; +interface BaseUndoLevel { + type: UndoLevelType; + bookmark: Bookmark | null; + beforeBookmark: Bookmark | null; +} +interface FragmentedUndoLevel extends BaseUndoLevel { + type: 'fragmented'; + fragments: string[]; + content: ''; +} +interface CompleteUndoLevel extends BaseUndoLevel { + type: 'complete'; + fragments: null; + content: string; +} +type NewUndoLevel = CompleteUndoLevel | FragmentedUndoLevel; +type UndoLevel = NewUndoLevel & { + bookmark: Bookmark; +}; +interface UndoManager { + data: UndoLevel[]; + typing: boolean; + add: (level?: Partial, event?: EditorEvent) => UndoLevel | null; + dispatchChange: () => void; + beforeChange: () => void; + undo: () => UndoLevel | undefined; + redo: () => UndoLevel | undefined; + clear: () => void; + reset: () => void; + hasUndo: () => boolean; + hasRedo: () => boolean; + transact: (callback: () => void) => UndoLevel | null; + ignore: (callback: () => void) => void; + extra: (callback1: () => void, callback2: () => void) => void; +} +type SchemaType = 'html4' | 'html5' | 'html5-strict'; +interface ElementSettings { + block_elements?: string; + boolean_attributes?: string; + move_caret_before_on_enter_elements?: string; + non_empty_elements?: string; + self_closing_elements?: string; + text_block_elements?: string; + text_inline_elements?: string; + void_elements?: string; + whitespace_elements?: string; + transparent_elements?: string; + wrap_block_elements?: string; +} +interface SchemaSettings extends ElementSettings { + custom_elements?: string | Record; + extended_valid_elements?: string; + invalid_elements?: string; + invalid_styles?: string | Record; + schema?: SchemaType; + valid_children?: string; + valid_classes?: string | Record; + valid_elements?: string; + valid_styles?: string | Record; + verify_html?: boolean; + padd_empty_block_inline_children?: boolean; +} +interface Attribute { + required?: boolean; + defaultValue?: string; + forcedValue?: string; + validValues?: Record; +} +interface DefaultAttribute { + name: string; + value: string; +} +interface AttributePattern extends Attribute { + pattern: RegExp; +} +interface ElementRule { + attributes: Record; + attributesDefault?: DefaultAttribute[]; + attributesForced?: DefaultAttribute[]; + attributesOrder: string[]; + attributePatterns?: AttributePattern[]; + attributesRequired?: string[]; + paddEmpty?: boolean; + removeEmpty?: boolean; + removeEmptyAttrs?: boolean; + paddInEmptyBlock?: boolean; +} +interface SchemaElement extends ElementRule { + outputName?: string; + parentsRequired?: string[]; + pattern?: RegExp; +} +interface SchemaMap { + [name: string]: {}; +} +interface SchemaRegExpMap { + [name: string]: RegExp; +} +interface CustomElementSpec { + extends?: string; + attributes?: string[]; + children?: string[]; + padEmpty?: boolean; +} +interface Schema { + type: SchemaType; + children: Record; + elements: Record; + getValidStyles: () => Record | undefined; + getValidClasses: () => Record | undefined; + getBlockElements: () => SchemaMap; + getInvalidStyles: () => Record | undefined; + getVoidElements: () => SchemaMap; + getTextBlockElements: () => SchemaMap; + getTextInlineElements: () => SchemaMap; + getBoolAttrs: () => SchemaMap; + getElementRule: (name: string) => SchemaElement | undefined; + getSelfClosingElements: () => SchemaMap; + getNonEmptyElements: () => SchemaMap; + getMoveCaretBeforeOnEnterElements: () => SchemaMap; + getWhitespaceElements: () => SchemaMap; + getTransparentElements: () => SchemaMap; + getSpecialElements: () => SchemaRegExpMap; + isValidChild: (name: string, child: string) => boolean; + isValid: (name: string, attr?: string) => boolean; + isBlock: (name: string) => boolean; + isInline: (name: string) => boolean; + isWrapper: (name: string) => boolean; + getCustomElements: () => SchemaMap; + addValidElements: (validElements: string) => void; + setValidElements: (validElements: string) => void; + addCustomElements: (customElements: string | Record) => void; + addValidChildren: (validChildren: any) => void; +} +type Attributes$1 = Array<{ + name: string; + value: string; +}> & { + map: Record; +}; +interface AstNodeConstructor { + readonly prototype: AstNode; + new (name: string, type: number): AstNode; + create(name: string, attrs?: Record): AstNode; +} +declare class AstNode { + static create(name: string, attrs?: Record): AstNode; + name: string; + type: number; + attributes?: Attributes$1; + value?: string; + parent?: AstNode | null; + firstChild?: AstNode | null; + lastChild?: AstNode | null; + next?: AstNode | null; + prev?: AstNode | null; + raw?: boolean; + constructor(name: string, type: number); + replace(node: AstNode): AstNode; + attr(name: string, value: string | null | undefined): AstNode | undefined; + attr(name: Record | undefined): AstNode | undefined; + attr(name: string): string | undefined; + clone(): AstNode; + wrap(wrapper: AstNode): AstNode; + unwrap(): void; + remove(): AstNode; + append(node: AstNode): AstNode; + insert(node: AstNode, refNode: AstNode, before?: boolean): AstNode; + getAll(name: string): AstNode[]; + children(): AstNode[]; + empty(): AstNode; + isEmpty(elements: SchemaMap, whitespace?: SchemaMap, predicate?: (node: AstNode) => boolean): boolean; + walk(prev?: boolean): AstNode | null | undefined; +} +type Content = string | AstNode; +type ContentFormat = 'raw' | 'text' | 'html' | 'tree'; +interface GetContentArgs { + format: ContentFormat; + get: boolean; + getInner: boolean; + no_events?: boolean; + save?: boolean; + source_view?: boolean; + [key: string]: any; +} +interface SetContentArgs { + format: string; + set: boolean; + content: Content; + no_events?: boolean; + no_selection?: boolean; + paste?: boolean; + load?: boolean; + initial?: boolean; + [key: string]: any; +} +interface GetSelectionContentArgs extends GetContentArgs { + selection?: boolean; + contextual?: boolean; +} +interface SetSelectionContentArgs extends SetContentArgs { + content: string; + selection?: boolean; +} +interface BlobInfoData { + id?: string; + name?: string; + filename?: string; + blob: Blob; + base64: string; + blobUri?: string; + uri?: string; +} +interface BlobInfo { + id: () => string; + name: () => string; + filename: () => string; + blob: () => Blob; + base64: () => string; + blobUri: () => string; + uri: () => string | undefined; +} +interface BlobCache { + create: { + (o: BlobInfoData): BlobInfo; + (id: string, blob: Blob, base64: string, name?: string, filename?: string): BlobInfo; + }; + add: (blobInfo: BlobInfo) => void; + get: (id: string) => BlobInfo | undefined; + getByUri: (blobUri: string) => BlobInfo | undefined; + getByData: (base64: string, type: string) => BlobInfo | undefined; + findFirst: (predicate: (blobInfo: BlobInfo) => boolean) => BlobInfo | undefined; + removeByUri: (blobUri: string) => void; + destroy: () => void; +} +interface BlobInfoImagePair { + image: HTMLImageElement; + blobInfo: BlobInfo; +} +declare class NodeChange { + private readonly editor; + private lastPath; + constructor(editor: Editor); + nodeChanged(args?: Record): void; + private isSameElementPath; +} +interface SelectionOverrides { + showCaret: (direction: number, node: HTMLElement, before: boolean, scrollIntoView?: boolean) => Range | null; + showBlockCaretContainer: (blockCaretContainer: HTMLElement) => void; + hideFakeCaret: () => void; + destroy: () => void; +} +interface Quirks { + refreshContentEditable(): void; + isHidden(): boolean; +} +type DecoratorData = Record; +type Decorator = (uid: string, data: DecoratorData) => { + attributes?: {}; + classes?: string[]; +}; +type AnnotationListener = (state: boolean, name: string, data?: { + uid: string; + nodes: any[]; +}) => void; +type AnnotationListenerApi = AnnotationListener; +interface AnnotatorSettings { + decorate: Decorator; + persistent?: boolean; +} +interface Annotator { + register: (name: string, settings: AnnotatorSettings) => void; + annotate: (name: string, data: DecoratorData) => void; + annotationChanged: (name: string, f: AnnotationListenerApi) => void; + remove: (name: string) => void; + removeAll: (name: string) => void; + getAll: (name: string) => Record; +} +interface IsEmptyOptions { + readonly skipBogus?: boolean; + readonly includeZwsp?: boolean; + readonly checkRootAsContent?: boolean; + readonly isContent?: (node: Node) => boolean; +} +interface GeomRect { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; +} +interface Rect { + inflate: (rect: GeomRect, w: number, h: number) => GeomRect; + relativePosition: (rect: GeomRect, targetRect: GeomRect, rel: string) => GeomRect; + findBestRelativePosition: (rect: GeomRect, targetRect: GeomRect, constrainRect: GeomRect, rels: string[]) => string | null; + intersect: (rect: GeomRect, cropRect: GeomRect) => GeomRect | null; + clamp: (rect: GeomRect, clampRect: GeomRect, fixedSize?: boolean) => GeomRect; + create: (x: number, y: number, w: number, h: number) => GeomRect; + fromClientRect: (clientRect: DOMRect) => GeomRect; +} +interface NotificationManagerImpl { + open: (spec: NotificationSpec, closeCallback: () => void, hasEditorFocus: () => boolean) => NotificationApi; + close: (notification: T) => void; + getArgs: (notification: T) => NotificationSpec; +} +interface NotificationSpec { + type?: 'info' | 'warning' | 'error' | 'success'; + text: string; + icon?: string; + progressBar?: boolean; + timeout?: number; +} +interface NotificationApi { + close: () => void; + progressBar: { + value: (percent: number) => void; + }; + text: (text: string) => void; + reposition: () => void; + getEl: () => HTMLElement; + settings: NotificationSpec; +} +interface NotificationManager { + open: (spec: NotificationSpec) => NotificationApi; + close: () => void; + getNotifications: () => NotificationApi[]; +} +interface UploadFailure { + message: string; + remove?: boolean; +} +type ProgressFn = (percent: number) => void; +type UploadHandler = (blobInfo: BlobInfo, progress: ProgressFn) => Promise; +interface UploadResult$2 { + url: string; + blobInfo: BlobInfo; + status: boolean; + error?: UploadFailure; +} +type BlockPatternTrigger = 'enter' | 'space'; +interface RawPattern { + start?: any; + end?: any; + format?: any; + cmd?: any; + value?: any; + replacement?: any; + trigger?: BlockPatternTrigger; +} +interface InlineBasePattern { + readonly start: string; + readonly end: string; +} +interface InlineFormatPattern extends InlineBasePattern { + readonly type: 'inline-format'; + readonly format: string[]; +} +interface InlineCmdPattern extends InlineBasePattern { + readonly type: 'inline-command'; + readonly cmd: string; + readonly value?: any; +} +type InlinePattern = InlineFormatPattern | InlineCmdPattern; +interface BlockBasePattern { + readonly start: string; + readonly trigger: BlockPatternTrigger; +} +interface BlockFormatPattern extends BlockBasePattern { + readonly type: 'block-format'; + readonly format: string; +} +interface BlockCmdPattern extends BlockBasePattern { + readonly type: 'block-command'; + readonly cmd: string; + readonly value?: any; +} +type BlockPattern = BlockFormatPattern | BlockCmdPattern; +type Pattern = InlinePattern | BlockPattern; +interface DynamicPatternContext { + readonly text: string; + readonly block: Element; +} +type DynamicPatternsLookup = (ctx: DynamicPatternContext) => Pattern[]; +type RawDynamicPatternsLookup = (ctx: DynamicPatternContext) => RawPattern[]; +interface AlertBannerSpec { + type: 'alertbanner'; + level: 'info' | 'warn' | 'error' | 'success'; + text: string; + icon: string; + url?: string; +} +interface ButtonSpec { + type: 'button'; + text: string; + enabled?: boolean; + primary?: boolean; + name?: string; + icon?: string; + borderless?: boolean; + buttonType?: 'primary' | 'secondary' | 'toolbar'; +} +interface FormComponentSpec { + type: string; + name: string; +} +interface FormComponentWithLabelSpec extends FormComponentSpec { + label?: string; +} +interface CheckboxSpec extends FormComponentSpec { + type: 'checkbox'; + label: string; + enabled?: boolean; +} +interface CollectionSpec extends FormComponentWithLabelSpec { + type: 'collection'; +} +interface CollectionItem { + value: string; + text: string; + icon: string; +} +interface ColorInputSpec extends FormComponentWithLabelSpec { + type: 'colorinput'; + storageKey?: string; +} +interface ColorPickerSpec extends FormComponentWithLabelSpec { + type: 'colorpicker'; +} +interface CustomEditorInit { + setValue: (value: string) => void; + getValue: () => string; + destroy: () => void; +} +type CustomEditorInitFn = (elm: HTMLElement, settings: any) => Promise; +interface CustomEditorOldSpec extends FormComponentSpec { + type: 'customeditor'; + tag?: string; + init: (e: HTMLElement) => Promise; +} +interface CustomEditorNewSpec extends FormComponentSpec { + type: 'customeditor'; + tag?: string; + scriptId: string; + scriptUrl: string; + onFocus?: (e: HTMLElement) => void; + settings?: any; +} +type CustomEditorSpec = CustomEditorOldSpec | CustomEditorNewSpec; +interface DropZoneSpec extends FormComponentWithLabelSpec { + type: 'dropzone'; +} +interface GridSpec { + type: 'grid'; + columns: number; + items: BodyComponentSpec[]; +} +interface HtmlPanelSpec { + type: 'htmlpanel'; + html: string; + onInit?: (el: HTMLElement) => void; + presets?: 'presentation' | 'document'; + stretched?: boolean; +} +interface IframeSpec extends FormComponentWithLabelSpec { + type: 'iframe'; + border?: boolean; + sandboxed?: boolean; + streamContent?: boolean; + transparent?: boolean; +} +interface ImagePreviewSpec extends FormComponentSpec { + type: 'imagepreview'; + height?: string; +} +interface InputSpec extends FormComponentWithLabelSpec { + type: 'input'; + inputMode?: string; + placeholder?: string; + maximized?: boolean; + enabled?: boolean; +} +type Alignment = 'start' | 'center' | 'end'; +interface LabelSpec { + type: 'label'; + label: string; + items: BodyComponentSpec[]; + align?: Alignment; + for?: string; +} +interface ListBoxSingleItemSpec { + text: string; + value: string; +} +interface ListBoxNestedItemSpec { + text: string; + items: ListBoxItemSpec[]; +} +type ListBoxItemSpec = ListBoxNestedItemSpec | ListBoxSingleItemSpec; +interface ListBoxSpec extends FormComponentWithLabelSpec { + type: 'listbox'; + items: ListBoxItemSpec[]; + disabled?: boolean; +} +interface PanelSpec { + type: 'panel'; + classes?: string[]; + items: BodyComponentSpec[]; +} +interface SelectBoxItemSpec { + text: string; + value: string; +} +interface SelectBoxSpec extends FormComponentWithLabelSpec { + type: 'selectbox'; + items: SelectBoxItemSpec[]; + size?: number; + enabled?: boolean; +} +interface SizeInputSpec extends FormComponentWithLabelSpec { + type: 'sizeinput'; + constrain?: boolean; + enabled?: boolean; +} +interface SliderSpec extends FormComponentSpec { + type: 'slider'; + label: string; + min?: number; + max?: number; +} +interface TableSpec { + type: 'table'; + header: string[]; + cells: string[][]; +} +interface TextAreaSpec extends FormComponentWithLabelSpec { + type: 'textarea'; + placeholder?: string; + maximized?: boolean; + enabled?: boolean; +} +interface BaseToolbarButtonSpec { + enabled?: boolean; + tooltip?: string; + icon?: string; + text?: string; + onSetup?: (api: I) => (api: I) => void; +} +interface BaseToolbarButtonInstanceApi { + isEnabled: () => boolean; + setEnabled: (state: boolean) => void; + setText: (text: string) => void; + setIcon: (icon: string) => void; +} +interface ToolbarButtonSpec extends BaseToolbarButtonSpec { + type?: 'button'; + onAction: (api: ToolbarButtonInstanceApi) => void; + shortcut?: string; +} +interface ToolbarButtonInstanceApi extends BaseToolbarButtonInstanceApi { +} +interface ToolbarGroupSetting { + name: string; + items: string[]; +} +type ToolbarConfig = string | ToolbarGroupSetting[]; +interface GroupToolbarButtonInstanceApi extends BaseToolbarButtonInstanceApi { +} +interface GroupToolbarButtonSpec extends BaseToolbarButtonSpec { + type?: 'grouptoolbarbutton'; + items?: ToolbarConfig; +} +interface CardImageSpec { + type: 'cardimage'; + src: string; + alt?: string; + classes?: string[]; +} +interface CardTextSpec { + type: 'cardtext'; + text: string; + name?: string; + classes?: string[]; +} +type CardItemSpec = CardContainerSpec | CardImageSpec | CardTextSpec; +type CardContainerDirection = 'vertical' | 'horizontal'; +type CardContainerAlign = 'left' | 'right'; +type CardContainerValign = 'top' | 'middle' | 'bottom'; +interface CardContainerSpec { + type: 'cardcontainer'; + items: CardItemSpec[]; + direction?: CardContainerDirection; + align?: CardContainerAlign; + valign?: CardContainerValign; +} +interface CommonMenuItemSpec { + enabled?: boolean; + text?: string; + value?: string; + meta?: Record; + shortcut?: string; +} +interface CommonMenuItemInstanceApi { + isEnabled: () => boolean; + setEnabled: (state: boolean) => void; +} +interface CardMenuItemInstanceApi extends CommonMenuItemInstanceApi { +} +interface CardMenuItemSpec extends Omit { + type: 'cardmenuitem'; + label?: string; + items: CardItemSpec[]; + onSetup?: (api: CardMenuItemInstanceApi) => (api: CardMenuItemInstanceApi) => void; + onAction?: (api: CardMenuItemInstanceApi) => void; +} +interface ChoiceMenuItemSpec extends CommonMenuItemSpec { + type?: 'choiceitem'; + icon?: string; +} +interface ChoiceMenuItemInstanceApi extends CommonMenuItemInstanceApi { + isActive: () => boolean; + setActive: (state: boolean) => void; +} +interface ContextMenuItem extends CommonMenuItemSpec { + text: string; + icon?: string; + type?: 'item'; + onAction: () => void; +} +interface ContextSubMenu extends CommonMenuItemSpec { + type: 'submenu'; + text: string; + icon?: string; + getSubmenuItems: () => string | Array; +} +type ContextMenuContents = string | ContextMenuItem | SeparatorMenuItemSpec | ContextSubMenu; +interface ContextMenuApi { + update: (element: Element) => string | Array; +} +interface FancyActionArgsMap { + 'inserttable': { + numRows: number; + numColumns: number; + }; + 'colorswatch': { + value: string; + }; +} +interface BaseFancyMenuItemSpec { + type: 'fancymenuitem'; + fancytype: T; + initData?: Record; + onAction?: (data: FancyActionArgsMap[T]) => void; +} +interface InsertTableMenuItemSpec extends BaseFancyMenuItemSpec<'inserttable'> { + fancytype: 'inserttable'; + initData?: {}; +} +interface ColorSwatchMenuItemSpec extends BaseFancyMenuItemSpec<'colorswatch'> { + fancytype: 'colorswatch'; + select?: (value: string) => boolean; + initData?: { + allowCustomColors?: boolean; + colors?: ChoiceMenuItemSpec[]; + storageKey?: string; + }; +} +type FancyMenuItemSpec = InsertTableMenuItemSpec | ColorSwatchMenuItemSpec; +interface MenuItemSpec extends CommonMenuItemSpec { + type?: 'menuitem'; + icon?: string; + onSetup?: (api: MenuItemInstanceApi) => (api: MenuItemInstanceApi) => void; + onAction?: (api: MenuItemInstanceApi) => void; +} +interface MenuItemInstanceApi extends CommonMenuItemInstanceApi { +} +interface SeparatorMenuItemSpec { + type?: 'separator'; + text?: string; +} +interface ToggleMenuItemSpec extends CommonMenuItemSpec { + type?: 'togglemenuitem'; + icon?: string; + active?: boolean; + onSetup?: (api: ToggleMenuItemInstanceApi) => void; + onAction: (api: ToggleMenuItemInstanceApi) => void; +} +interface ToggleMenuItemInstanceApi extends CommonMenuItemInstanceApi { + isActive: () => boolean; + setActive: (state: boolean) => void; +} +type NestedMenuItemContents = string | MenuItemSpec | NestedMenuItemSpec | ToggleMenuItemSpec | SeparatorMenuItemSpec | FancyMenuItemSpec; +interface NestedMenuItemSpec extends CommonMenuItemSpec { + type?: 'nestedmenuitem'; + icon?: string; + getSubmenuItems: () => string | Array; + onSetup?: (api: NestedMenuItemInstanceApi) => (api: NestedMenuItemInstanceApi) => void; +} +interface NestedMenuItemInstanceApi extends CommonMenuItemInstanceApi { + setTooltip: (tooltip: string) => void; + setIconFill: (id: string, value: string) => void; +} +type MenuButtonItemTypes = NestedMenuItemContents; +type SuccessCallback$1 = (menu: string | MenuButtonItemTypes[]) => void; +interface MenuButtonFetchContext { + pattern: string; +} +interface BaseMenuButtonSpec { + text?: string; + tooltip?: string; + icon?: string; + search?: boolean | { + placeholder?: string; + }; + fetch: (success: SuccessCallback$1, fetchContext: MenuButtonFetchContext, api: BaseMenuButtonInstanceApi) => void; + onSetup?: (api: BaseMenuButtonInstanceApi) => (api: BaseMenuButtonInstanceApi) => void; +} +interface BaseMenuButtonInstanceApi { + isEnabled: () => boolean; + setEnabled: (state: boolean) => void; + isActive: () => boolean; + setActive: (state: boolean) => void; + setText: (text: string) => void; + setIcon: (icon: string) => void; +} +interface ToolbarMenuButtonSpec extends BaseMenuButtonSpec { + type?: 'menubutton'; + onSetup?: (api: ToolbarMenuButtonInstanceApi) => (api: ToolbarMenuButtonInstanceApi) => void; +} +interface ToolbarMenuButtonInstanceApi extends BaseMenuButtonInstanceApi { +} +type ToolbarSplitButtonItemTypes = ChoiceMenuItemSpec | SeparatorMenuItemSpec; +type SuccessCallback = (menu: ToolbarSplitButtonItemTypes[]) => void; +type SelectPredicate = (value: string) => boolean; +type PresetTypes = 'color' | 'normal' | 'listpreview'; +type ColumnTypes$1 = number | 'auto'; +interface ToolbarSplitButtonSpec { + type?: 'splitbutton'; + tooltip?: string; + icon?: string; + text?: string; + select?: SelectPredicate; + presets?: PresetTypes; + columns?: ColumnTypes$1; + fetch: (success: SuccessCallback) => void; + onSetup?: (api: ToolbarSplitButtonInstanceApi) => (api: ToolbarSplitButtonInstanceApi) => void; + onAction: (api: ToolbarSplitButtonInstanceApi) => void; + onItemAction: (api: ToolbarSplitButtonInstanceApi, value: string) => void; +} +interface ToolbarSplitButtonInstanceApi { + isEnabled: () => boolean; + setEnabled: (state: boolean) => void; + setIconFill: (id: string, value: string) => void; + isActive: () => boolean; + setActive: (state: boolean) => void; + setTooltip: (tooltip: string) => void; + setText: (text: string) => void; + setIcon: (icon: string) => void; +} +interface BaseToolbarToggleButtonSpec extends BaseToolbarButtonSpec { + active?: boolean; +} +interface BaseToolbarToggleButtonInstanceApi extends BaseToolbarButtonInstanceApi { + isActive: () => boolean; + setActive: (state: boolean) => void; +} +interface ToolbarToggleButtonSpec extends BaseToolbarToggleButtonSpec { + type?: 'togglebutton'; + onAction: (api: ToolbarToggleButtonInstanceApi) => void; + shortcut?: string; +} +interface ToolbarToggleButtonInstanceApi extends BaseToolbarToggleButtonInstanceApi { +} +type Id = string; +interface TreeSpec { + type: 'tree'; + items: TreeItemSpec[]; + onLeafAction?: (id: Id) => void; + defaultExpandedIds?: Id[]; + onToggleExpand?: (expandedIds: Id[], { expanded, node }: { + expanded: boolean; + node: Id; + }) => void; + defaultSelectedId?: Id; +} +interface BaseTreeItemSpec { + title: string; + id: Id; + menu?: ToolbarMenuButtonSpec; +} +interface DirectorySpec extends BaseTreeItemSpec { + type: 'directory'; + children: TreeItemSpec[]; +} +interface LeafSpec extends BaseTreeItemSpec { + type: 'leaf'; +} +type TreeItemSpec = DirectorySpec | LeafSpec; +interface UrlInputSpec extends FormComponentWithLabelSpec { + type: 'urlinput'; + filetype?: 'image' | 'media' | 'file'; + enabled?: boolean; + picker_text?: string; +} +interface UrlInputData { + value: string; + meta: { + text?: string; + }; +} +type BodyComponentSpec = BarSpec | ButtonSpec | CheckboxSpec | TextAreaSpec | InputSpec | ListBoxSpec | SelectBoxSpec | SizeInputSpec | SliderSpec | IframeSpec | HtmlPanelSpec | UrlInputSpec | DropZoneSpec | ColorInputSpec | GridSpec | ColorPickerSpec | ImagePreviewSpec | AlertBannerSpec | CollectionSpec | LabelSpec | TableSpec | TreeSpec | PanelSpec | CustomEditorSpec; +interface BarSpec { + type: 'bar'; + items: BodyComponentSpec[]; +} +interface DialogToggleMenuItemSpec extends CommonMenuItemSpec { + type?: 'togglemenuitem'; + name: string; +} +type DialogFooterMenuButtonItemSpec = DialogToggleMenuItemSpec; +interface BaseDialogFooterButtonSpec { + name?: string; + align?: 'start' | 'end'; + primary?: boolean; + enabled?: boolean; + icon?: string; + buttonType?: 'primary' | 'secondary'; +} +interface DialogFooterNormalButtonSpec extends BaseDialogFooterButtonSpec { + type: 'submit' | 'cancel' | 'custom'; + text: string; +} +interface DialogFooterMenuButtonSpec extends BaseDialogFooterButtonSpec { + type: 'menu'; + text?: string; + tooltip?: string; + icon?: string; + items: DialogFooterMenuButtonItemSpec[]; +} +interface DialogFooterToggleButtonSpec extends BaseDialogFooterButtonSpec { + type: 'togglebutton'; + tooltip?: string; + icon?: string; + text?: string; + active?: boolean; +} +type DialogFooterButtonSpec = DialogFooterNormalButtonSpec | DialogFooterMenuButtonSpec | DialogFooterToggleButtonSpec; +interface TabSpec { + name?: string; + title: string; + items: BodyComponentSpec[]; +} +interface TabPanelSpec { + type: 'tabpanel'; + tabs: TabSpec[]; +} +type DialogDataItem = any; +type DialogData = Record; +interface DialogInstanceApi { + getData: () => T; + setData: (data: Partial) => void; + setEnabled: (name: string, state: boolean) => void; + focus: (name: string) => void; + showTab: (name: string) => void; + redial: (nu: DialogSpec) => void; + block: (msg: string) => void; + unblock: () => void; + toggleFullscreen: () => void; + close: () => void; +} +interface DialogActionDetails { + name: string; + value?: any; +} +interface DialogChangeDetails { + name: keyof T; +} +interface DialogTabChangeDetails { + newTabName: string; + oldTabName: string; +} +type DialogActionHandler = (api: DialogInstanceApi, details: DialogActionDetails) => void; +type DialogChangeHandler = (api: DialogInstanceApi, details: DialogChangeDetails) => void; +type DialogSubmitHandler = (api: DialogInstanceApi) => void; +type DialogCloseHandler = () => void; +type DialogCancelHandler = (api: DialogInstanceApi) => void; +type DialogTabChangeHandler = (api: DialogInstanceApi, details: DialogTabChangeDetails) => void; +type DialogSize = 'normal' | 'medium' | 'large'; +interface DialogSpec { + title: string; + size?: DialogSize; + body: TabPanelSpec | PanelSpec; + buttons?: DialogFooterButtonSpec[]; + initialData?: Partial; + onAction?: DialogActionHandler; + onChange?: DialogChangeHandler; + onSubmit?: DialogSubmitHandler; + onClose?: DialogCloseHandler; + onCancel?: DialogCancelHandler; + onTabChange?: DialogTabChangeHandler; +} +interface UrlDialogInstanceApi { + block: (msg: string) => void; + unblock: () => void; + close: () => void; + sendMessage: (msg: any) => void; +} +interface UrlDialogActionDetails { + name: string; + value?: any; +} +interface UrlDialogMessage { + mceAction: string; + [key: string]: any; +} +type UrlDialogActionHandler = (api: UrlDialogInstanceApi, actions: UrlDialogActionDetails) => void; +type UrlDialogCloseHandler = () => void; +type UrlDialogCancelHandler = (api: UrlDialogInstanceApi) => void; +type UrlDialogMessageHandler = (api: UrlDialogInstanceApi, message: UrlDialogMessage) => void; +interface UrlDialogFooterButtonSpec extends DialogFooterNormalButtonSpec { + type: 'cancel' | 'custom'; +} +interface UrlDialogSpec { + title: string; + url: string; + height?: number; + width?: number; + buttons?: UrlDialogFooterButtonSpec[]; + onAction?: UrlDialogActionHandler; + onClose?: UrlDialogCloseHandler; + onCancel?: UrlDialogCancelHandler; + onMessage?: UrlDialogMessageHandler; +} +type ColumnTypes = number | 'auto'; +type SeparatorItemSpec = SeparatorMenuItemSpec; +interface AutocompleterItemSpec { + type?: 'autocompleteitem'; + value: string; + text?: string; + icon?: string; + meta?: Record; +} +type AutocompleterContents = SeparatorItemSpec | AutocompleterItemSpec | CardMenuItemSpec; +interface AutocompleterSpec { + type?: 'autocompleter'; + trigger: string; + minChars?: number; + columns?: ColumnTypes; + matches?: (rng: Range, text: string, pattern: string) => boolean; + fetch: (pattern: string, maxResults: number, fetchOptions: Record) => Promise; + onAction: (autocompleterApi: AutocompleterInstanceApi, rng: Range, value: string, meta: Record) => void; + maxResults?: number; + highlightOn?: string[]; +} +interface AutocompleterInstanceApi { + hide: () => void; + reload: (fetchOptions: Record) => void; +} +type ContextPosition = 'node' | 'selection' | 'line'; +type ContextScope = 'node' | 'editor'; +interface ContextBarSpec { + predicate?: (elem: Element) => boolean; + position?: ContextPosition; + scope?: ContextScope; +} +interface ContextFormLaunchButtonApi extends BaseToolbarButtonSpec { + type: 'contextformbutton'; +} +interface ContextFormLaunchToggleButtonSpec extends BaseToolbarToggleButtonSpec { + type: 'contextformtogglebutton'; +} +interface ContextFormButtonInstanceApi extends BaseToolbarButtonInstanceApi { +} +interface ContextFormToggleButtonInstanceApi extends BaseToolbarToggleButtonInstanceApi { +} +interface ContextFormButtonSpec extends BaseToolbarButtonSpec { + type?: 'contextformbutton'; + primary?: boolean; + onAction: (formApi: ContextFormInstanceApi, api: ContextFormButtonInstanceApi) => void; +} +interface ContextFormToggleButtonSpec extends BaseToolbarToggleButtonSpec { + type?: 'contextformtogglebutton'; + onAction: (formApi: ContextFormInstanceApi, buttonApi: ContextFormToggleButtonInstanceApi) => void; + primary?: boolean; +} +interface ContextFormInstanceApi { + hide: () => void; + getValue: () => string; +} +interface ContextFormSpec extends ContextBarSpec { + type?: 'contextform'; + initValue?: () => string; + label?: string; + launch?: ContextFormLaunchButtonApi | ContextFormLaunchToggleButtonSpec; + commands: Array; +} +interface ContextToolbarSpec extends ContextBarSpec { + type?: 'contexttoolbar'; + items: string; +} +type PublicDialog_d_AlertBannerSpec = AlertBannerSpec; +type PublicDialog_d_BarSpec = BarSpec; +type PublicDialog_d_BodyComponentSpec = BodyComponentSpec; +type PublicDialog_d_ButtonSpec = ButtonSpec; +type PublicDialog_d_CheckboxSpec = CheckboxSpec; +type PublicDialog_d_CollectionItem = CollectionItem; +type PublicDialog_d_CollectionSpec = CollectionSpec; +type PublicDialog_d_ColorInputSpec = ColorInputSpec; +type PublicDialog_d_ColorPickerSpec = ColorPickerSpec; +type PublicDialog_d_CustomEditorSpec = CustomEditorSpec; +type PublicDialog_d_CustomEditorInit = CustomEditorInit; +type PublicDialog_d_CustomEditorInitFn = CustomEditorInitFn; +type PublicDialog_d_DialogData = DialogData; +type PublicDialog_d_DialogSize = DialogSize; +type PublicDialog_d_DialogSpec = DialogSpec; +type PublicDialog_d_DialogInstanceApi = DialogInstanceApi; +type PublicDialog_d_DialogFooterButtonSpec = DialogFooterButtonSpec; +type PublicDialog_d_DialogActionDetails = DialogActionDetails; +type PublicDialog_d_DialogChangeDetails = DialogChangeDetails; +type PublicDialog_d_DialogTabChangeDetails = DialogTabChangeDetails; +type PublicDialog_d_DropZoneSpec = DropZoneSpec; +type PublicDialog_d_GridSpec = GridSpec; +type PublicDialog_d_HtmlPanelSpec = HtmlPanelSpec; +type PublicDialog_d_IframeSpec = IframeSpec; +type PublicDialog_d_ImagePreviewSpec = ImagePreviewSpec; +type PublicDialog_d_InputSpec = InputSpec; +type PublicDialog_d_LabelSpec = LabelSpec; +type PublicDialog_d_ListBoxSpec = ListBoxSpec; +type PublicDialog_d_ListBoxItemSpec = ListBoxItemSpec; +type PublicDialog_d_ListBoxNestedItemSpec = ListBoxNestedItemSpec; +type PublicDialog_d_ListBoxSingleItemSpec = ListBoxSingleItemSpec; +type PublicDialog_d_PanelSpec = PanelSpec; +type PublicDialog_d_SelectBoxSpec = SelectBoxSpec; +type PublicDialog_d_SelectBoxItemSpec = SelectBoxItemSpec; +type PublicDialog_d_SizeInputSpec = SizeInputSpec; +type PublicDialog_d_SliderSpec = SliderSpec; +type PublicDialog_d_TableSpec = TableSpec; +type PublicDialog_d_TabSpec = TabSpec; +type PublicDialog_d_TabPanelSpec = TabPanelSpec; +type PublicDialog_d_TextAreaSpec = TextAreaSpec; +type PublicDialog_d_TreeSpec = TreeSpec; +type PublicDialog_d_TreeItemSpec = TreeItemSpec; +type PublicDialog_d_UrlInputData = UrlInputData; +type PublicDialog_d_UrlInputSpec = UrlInputSpec; +type PublicDialog_d_UrlDialogSpec = UrlDialogSpec; +type PublicDialog_d_UrlDialogFooterButtonSpec = UrlDialogFooterButtonSpec; +type PublicDialog_d_UrlDialogInstanceApi = UrlDialogInstanceApi; +type PublicDialog_d_UrlDialogActionDetails = UrlDialogActionDetails; +type PublicDialog_d_UrlDialogMessage = UrlDialogMessage; +declare namespace PublicDialog_d { + export { PublicDialog_d_AlertBannerSpec as AlertBannerSpec, PublicDialog_d_BarSpec as BarSpec, PublicDialog_d_BodyComponentSpec as BodyComponentSpec, PublicDialog_d_ButtonSpec as ButtonSpec, PublicDialog_d_CheckboxSpec as CheckboxSpec, PublicDialog_d_CollectionItem as CollectionItem, PublicDialog_d_CollectionSpec as CollectionSpec, PublicDialog_d_ColorInputSpec as ColorInputSpec, PublicDialog_d_ColorPickerSpec as ColorPickerSpec, PublicDialog_d_CustomEditorSpec as CustomEditorSpec, PublicDialog_d_CustomEditorInit as CustomEditorInit, PublicDialog_d_CustomEditorInitFn as CustomEditorInitFn, PublicDialog_d_DialogData as DialogData, PublicDialog_d_DialogSize as DialogSize, PublicDialog_d_DialogSpec as DialogSpec, PublicDialog_d_DialogInstanceApi as DialogInstanceApi, PublicDialog_d_DialogFooterButtonSpec as DialogFooterButtonSpec, PublicDialog_d_DialogActionDetails as DialogActionDetails, PublicDialog_d_DialogChangeDetails as DialogChangeDetails, PublicDialog_d_DialogTabChangeDetails as DialogTabChangeDetails, PublicDialog_d_DropZoneSpec as DropZoneSpec, PublicDialog_d_GridSpec as GridSpec, PublicDialog_d_HtmlPanelSpec as HtmlPanelSpec, PublicDialog_d_IframeSpec as IframeSpec, PublicDialog_d_ImagePreviewSpec as ImagePreviewSpec, PublicDialog_d_InputSpec as InputSpec, PublicDialog_d_LabelSpec as LabelSpec, PublicDialog_d_ListBoxSpec as ListBoxSpec, PublicDialog_d_ListBoxItemSpec as ListBoxItemSpec, PublicDialog_d_ListBoxNestedItemSpec as ListBoxNestedItemSpec, PublicDialog_d_ListBoxSingleItemSpec as ListBoxSingleItemSpec, PublicDialog_d_PanelSpec as PanelSpec, PublicDialog_d_SelectBoxSpec as SelectBoxSpec, PublicDialog_d_SelectBoxItemSpec as SelectBoxItemSpec, PublicDialog_d_SizeInputSpec as SizeInputSpec, PublicDialog_d_SliderSpec as SliderSpec, PublicDialog_d_TableSpec as TableSpec, PublicDialog_d_TabSpec as TabSpec, PublicDialog_d_TabPanelSpec as TabPanelSpec, PublicDialog_d_TextAreaSpec as TextAreaSpec, PublicDialog_d_TreeSpec as TreeSpec, PublicDialog_d_TreeItemSpec as TreeItemSpec, DirectorySpec as TreeDirectorySpec, LeafSpec as TreeLeafSpec, PublicDialog_d_UrlInputData as UrlInputData, PublicDialog_d_UrlInputSpec as UrlInputSpec, PublicDialog_d_UrlDialogSpec as UrlDialogSpec, PublicDialog_d_UrlDialogFooterButtonSpec as UrlDialogFooterButtonSpec, PublicDialog_d_UrlDialogInstanceApi as UrlDialogInstanceApi, PublicDialog_d_UrlDialogActionDetails as UrlDialogActionDetails, PublicDialog_d_UrlDialogMessage as UrlDialogMessage, }; +} +type PublicInlineContent_d_AutocompleterSpec = AutocompleterSpec; +type PublicInlineContent_d_AutocompleterItemSpec = AutocompleterItemSpec; +type PublicInlineContent_d_AutocompleterContents = AutocompleterContents; +type PublicInlineContent_d_AutocompleterInstanceApi = AutocompleterInstanceApi; +type PublicInlineContent_d_ContextPosition = ContextPosition; +type PublicInlineContent_d_ContextScope = ContextScope; +type PublicInlineContent_d_ContextFormSpec = ContextFormSpec; +type PublicInlineContent_d_ContextFormInstanceApi = ContextFormInstanceApi; +type PublicInlineContent_d_ContextFormButtonSpec = ContextFormButtonSpec; +type PublicInlineContent_d_ContextFormButtonInstanceApi = ContextFormButtonInstanceApi; +type PublicInlineContent_d_ContextFormToggleButtonSpec = ContextFormToggleButtonSpec; +type PublicInlineContent_d_ContextFormToggleButtonInstanceApi = ContextFormToggleButtonInstanceApi; +type PublicInlineContent_d_ContextToolbarSpec = ContextToolbarSpec; +type PublicInlineContent_d_SeparatorItemSpec = SeparatorItemSpec; +declare namespace PublicInlineContent_d { + export { PublicInlineContent_d_AutocompleterSpec as AutocompleterSpec, PublicInlineContent_d_AutocompleterItemSpec as AutocompleterItemSpec, PublicInlineContent_d_AutocompleterContents as AutocompleterContents, PublicInlineContent_d_AutocompleterInstanceApi as AutocompleterInstanceApi, PublicInlineContent_d_ContextPosition as ContextPosition, PublicInlineContent_d_ContextScope as ContextScope, PublicInlineContent_d_ContextFormSpec as ContextFormSpec, PublicInlineContent_d_ContextFormInstanceApi as ContextFormInstanceApi, PublicInlineContent_d_ContextFormButtonSpec as ContextFormButtonSpec, PublicInlineContent_d_ContextFormButtonInstanceApi as ContextFormButtonInstanceApi, PublicInlineContent_d_ContextFormToggleButtonSpec as ContextFormToggleButtonSpec, PublicInlineContent_d_ContextFormToggleButtonInstanceApi as ContextFormToggleButtonInstanceApi, PublicInlineContent_d_ContextToolbarSpec as ContextToolbarSpec, PublicInlineContent_d_SeparatorItemSpec as SeparatorItemSpec, }; +} +type PublicMenu_d_MenuItemSpec = MenuItemSpec; +type PublicMenu_d_MenuItemInstanceApi = MenuItemInstanceApi; +type PublicMenu_d_NestedMenuItemContents = NestedMenuItemContents; +type PublicMenu_d_NestedMenuItemSpec = NestedMenuItemSpec; +type PublicMenu_d_NestedMenuItemInstanceApi = NestedMenuItemInstanceApi; +type PublicMenu_d_FancyMenuItemSpec = FancyMenuItemSpec; +type PublicMenu_d_ColorSwatchMenuItemSpec = ColorSwatchMenuItemSpec; +type PublicMenu_d_InsertTableMenuItemSpec = InsertTableMenuItemSpec; +type PublicMenu_d_ToggleMenuItemSpec = ToggleMenuItemSpec; +type PublicMenu_d_ToggleMenuItemInstanceApi = ToggleMenuItemInstanceApi; +type PublicMenu_d_ChoiceMenuItemSpec = ChoiceMenuItemSpec; +type PublicMenu_d_ChoiceMenuItemInstanceApi = ChoiceMenuItemInstanceApi; +type PublicMenu_d_SeparatorMenuItemSpec = SeparatorMenuItemSpec; +type PublicMenu_d_ContextMenuApi = ContextMenuApi; +type PublicMenu_d_ContextMenuContents = ContextMenuContents; +type PublicMenu_d_ContextMenuItem = ContextMenuItem; +type PublicMenu_d_ContextSubMenu = ContextSubMenu; +type PublicMenu_d_CardMenuItemSpec = CardMenuItemSpec; +type PublicMenu_d_CardMenuItemInstanceApi = CardMenuItemInstanceApi; +type PublicMenu_d_CardItemSpec = CardItemSpec; +type PublicMenu_d_CardContainerSpec = CardContainerSpec; +type PublicMenu_d_CardImageSpec = CardImageSpec; +type PublicMenu_d_CardTextSpec = CardTextSpec; +declare namespace PublicMenu_d { + export { PublicMenu_d_MenuItemSpec as MenuItemSpec, PublicMenu_d_MenuItemInstanceApi as MenuItemInstanceApi, PublicMenu_d_NestedMenuItemContents as NestedMenuItemContents, PublicMenu_d_NestedMenuItemSpec as NestedMenuItemSpec, PublicMenu_d_NestedMenuItemInstanceApi as NestedMenuItemInstanceApi, PublicMenu_d_FancyMenuItemSpec as FancyMenuItemSpec, PublicMenu_d_ColorSwatchMenuItemSpec as ColorSwatchMenuItemSpec, PublicMenu_d_InsertTableMenuItemSpec as InsertTableMenuItemSpec, PublicMenu_d_ToggleMenuItemSpec as ToggleMenuItemSpec, PublicMenu_d_ToggleMenuItemInstanceApi as ToggleMenuItemInstanceApi, PublicMenu_d_ChoiceMenuItemSpec as ChoiceMenuItemSpec, PublicMenu_d_ChoiceMenuItemInstanceApi as ChoiceMenuItemInstanceApi, PublicMenu_d_SeparatorMenuItemSpec as SeparatorMenuItemSpec, PublicMenu_d_ContextMenuApi as ContextMenuApi, PublicMenu_d_ContextMenuContents as ContextMenuContents, PublicMenu_d_ContextMenuItem as ContextMenuItem, PublicMenu_d_ContextSubMenu as ContextSubMenu, PublicMenu_d_CardMenuItemSpec as CardMenuItemSpec, PublicMenu_d_CardMenuItemInstanceApi as CardMenuItemInstanceApi, PublicMenu_d_CardItemSpec as CardItemSpec, PublicMenu_d_CardContainerSpec as CardContainerSpec, PublicMenu_d_CardImageSpec as CardImageSpec, PublicMenu_d_CardTextSpec as CardTextSpec, }; +} +interface SidebarInstanceApi { + element: () => HTMLElement; +} +interface SidebarSpec { + icon?: string; + tooltip?: string; + onShow?: (api: SidebarInstanceApi) => void; + onSetup?: (api: SidebarInstanceApi) => (api: SidebarInstanceApi) => void; + onHide?: (api: SidebarInstanceApi) => void; +} +type PublicSidebar_d_SidebarSpec = SidebarSpec; +type PublicSidebar_d_SidebarInstanceApi = SidebarInstanceApi; +declare namespace PublicSidebar_d { + export { PublicSidebar_d_SidebarSpec as SidebarSpec, PublicSidebar_d_SidebarInstanceApi as SidebarInstanceApi, }; +} +type PublicToolbar_d_ToolbarButtonSpec = ToolbarButtonSpec; +type PublicToolbar_d_ToolbarButtonInstanceApi = ToolbarButtonInstanceApi; +type PublicToolbar_d_ToolbarSplitButtonSpec = ToolbarSplitButtonSpec; +type PublicToolbar_d_ToolbarSplitButtonInstanceApi = ToolbarSplitButtonInstanceApi; +type PublicToolbar_d_ToolbarMenuButtonSpec = ToolbarMenuButtonSpec; +type PublicToolbar_d_ToolbarMenuButtonInstanceApi = ToolbarMenuButtonInstanceApi; +type PublicToolbar_d_ToolbarToggleButtonSpec = ToolbarToggleButtonSpec; +type PublicToolbar_d_ToolbarToggleButtonInstanceApi = ToolbarToggleButtonInstanceApi; +type PublicToolbar_d_GroupToolbarButtonSpec = GroupToolbarButtonSpec; +type PublicToolbar_d_GroupToolbarButtonInstanceApi = GroupToolbarButtonInstanceApi; +declare namespace PublicToolbar_d { + export { PublicToolbar_d_ToolbarButtonSpec as ToolbarButtonSpec, PublicToolbar_d_ToolbarButtonInstanceApi as ToolbarButtonInstanceApi, PublicToolbar_d_ToolbarSplitButtonSpec as ToolbarSplitButtonSpec, PublicToolbar_d_ToolbarSplitButtonInstanceApi as ToolbarSplitButtonInstanceApi, PublicToolbar_d_ToolbarMenuButtonSpec as ToolbarMenuButtonSpec, PublicToolbar_d_ToolbarMenuButtonInstanceApi as ToolbarMenuButtonInstanceApi, PublicToolbar_d_ToolbarToggleButtonSpec as ToolbarToggleButtonSpec, PublicToolbar_d_ToolbarToggleButtonInstanceApi as ToolbarToggleButtonInstanceApi, PublicToolbar_d_GroupToolbarButtonSpec as GroupToolbarButtonSpec, PublicToolbar_d_GroupToolbarButtonInstanceApi as GroupToolbarButtonInstanceApi, }; +} +interface ViewButtonApi { + setIcon: (newIcon: string) => void; +} +interface ViewToggleButtonApi extends ViewButtonApi { + isActive: () => boolean; + setActive: (state: boolean) => void; +} +interface BaseButtonSpec { + text?: string; + icon?: string; + tooltip?: string; + buttonType?: 'primary' | 'secondary'; + borderless?: boolean; + onAction: (api: Api) => void; +} +interface ViewNormalButtonSpec extends BaseButtonSpec { + text: string; + type: 'button'; +} +interface ViewToggleButtonSpec extends BaseButtonSpec { + type: 'togglebutton'; + active?: boolean; + onAction: (api: ViewToggleButtonApi) => void; +} +interface ViewButtonsGroupSpec { + type: 'group'; + buttons: Array; +} +type ViewButtonSpec = ViewNormalButtonSpec | ViewToggleButtonSpec | ViewButtonsGroupSpec; +interface ViewInstanceApi { + getContainer: () => HTMLElement; +} +interface ViewSpec { + buttons?: ViewButtonSpec[]; + onShow: (api: ViewInstanceApi) => void; + onHide: (api: ViewInstanceApi) => void; +} +type PublicView_d_ViewSpec = ViewSpec; +type PublicView_d_ViewInstanceApi = ViewInstanceApi; +declare namespace PublicView_d { + export { PublicView_d_ViewSpec as ViewSpec, PublicView_d_ViewInstanceApi as ViewInstanceApi, }; +} +interface Registry$1 { + addButton: (name: string, spec: ToolbarButtonSpec) => void; + addGroupToolbarButton: (name: string, spec: GroupToolbarButtonSpec) => void; + addToggleButton: (name: string, spec: ToolbarToggleButtonSpec) => void; + addMenuButton: (name: string, spec: ToolbarMenuButtonSpec) => void; + addSplitButton: (name: string, spec: ToolbarSplitButtonSpec) => void; + addMenuItem: (name: string, spec: MenuItemSpec) => void; + addNestedMenuItem: (name: string, spec: NestedMenuItemSpec) => void; + addToggleMenuItem: (name: string, spec: ToggleMenuItemSpec) => void; + addContextMenu: (name: string, spec: ContextMenuApi) => void; + addContextToolbar: (name: string, spec: ContextToolbarSpec) => void; + addContextForm: (name: string, spec: ContextFormSpec) => void; + addIcon: (name: string, svgData: string) => void; + addAutocompleter: (name: string, spec: AutocompleterSpec) => void; + addSidebar: (name: string, spec: SidebarSpec) => void; + addView: (name: string, spec: ViewSpec) => void; + getAll: () => { + buttons: Record; + menuItems: Record; + popups: Record; + contextMenus: Record; + contextToolbars: Record; + icons: Record; + sidebars: Record; + views: Record; + }; +} +interface AutocompleteLookupData { + readonly matchText: string; + readonly items: AutocompleterContents[]; + readonly columns: ColumnTypes; + readonly onAction: (autoApi: AutocompleterInstanceApi, rng: Range, value: string, meta: Record) => void; + readonly highlightOn: string[]; +} +interface AutocompleterEventArgs { + readonly lookupData: AutocompleteLookupData[]; +} +interface RangeLikeObject { + startContainer: Node; + startOffset: number; + endContainer: Node; + endOffset: number; +} +type ApplyFormat = BlockFormat | InlineFormat | SelectorFormat; +type RemoveFormat = RemoveBlockFormat | RemoveInlineFormat | RemoveSelectorFormat; +type Format = ApplyFormat | RemoveFormat; +type Formats = Record; +type FormatAttrOrStyleValue = string | ((vars?: FormatVars) => string | null); +type FormatVars = Record; +interface BaseFormat { + ceFalseOverride?: boolean; + classes?: string | string[]; + collapsed?: boolean; + exact?: boolean; + expand?: boolean; + links?: boolean; + mixed?: boolean; + block_expand?: boolean; + onmatch?: (node: Element, fmt: T, itemName: string) => boolean; + remove?: 'none' | 'empty' | 'all'; + remove_similar?: boolean; + split?: boolean; + deep?: boolean; + preserve_attributes?: string[]; +} +interface Block { + block: string; + list_block?: string; + wrapper?: boolean; +} +interface Inline { + inline: string; +} +interface Selector { + selector: string; + inherit?: boolean; +} +interface CommonFormat extends BaseFormat { + attributes?: Record; + styles?: Record; + toggle?: boolean; + preview?: string | false; + onformat?: (elm: Element, fmt: T, vars?: FormatVars, node?: Node | RangeLikeObject | null) => void; + clear_child_styles?: boolean; + merge_siblings?: boolean; + merge_with_parents?: boolean; +} +interface BlockFormat extends Block, CommonFormat { +} +interface InlineFormat extends Inline, CommonFormat { +} +interface SelectorFormat extends Selector, CommonFormat { +} +interface CommonRemoveFormat extends BaseFormat { + attributes?: string[] | Record; + styles?: string[] | Record; +} +interface RemoveBlockFormat extends Block, CommonRemoveFormat { +} +interface RemoveInlineFormat extends Inline, CommonRemoveFormat { +} +interface RemoveSelectorFormat extends Selector, CommonRemoveFormat { +} +interface Filter { + name: string; + callbacks: C[]; +} +interface ParserArgs { + getInner?: boolean | number; + forced_root_block?: boolean | string; + context?: string; + isRootContent?: boolean; + format?: string; + invalid?: boolean; + no_events?: boolean; + [key: string]: any; +} +type ParserFilterCallback = (nodes: AstNode[], name: string, args: ParserArgs) => void; +interface ParserFilter extends Filter { +} +interface DomParserSettings { + allow_html_data_urls?: boolean; + allow_svg_data_urls?: boolean; + allow_conditional_comments?: boolean; + allow_html_in_named_anchor?: boolean; + allow_script_urls?: boolean; + allow_unsafe_link_target?: boolean; + blob_cache?: BlobCache; + convert_fonts_to_spans?: boolean; + convert_unsafe_embeds?: boolean; + document?: Document; + fix_list_elements?: boolean; + font_size_legacy_values?: string; + forced_root_block?: boolean | string; + forced_root_block_attrs?: Record; + inline_styles?: boolean; + pad_empty_with_br?: boolean; + preserve_cdata?: boolean; + root_name?: string; + sandbox_iframes?: boolean; + sandbox_iframes_exclusions?: string[]; + sanitize?: boolean; + validate?: boolean; +} +interface DomParser { + schema: Schema; + addAttributeFilter: (name: string, callback: ParserFilterCallback) => void; + getAttributeFilters: () => ParserFilter[]; + removeAttributeFilter: (name: string, callback?: ParserFilterCallback) => void; + addNodeFilter: (name: string, callback: ParserFilterCallback) => void; + getNodeFilters: () => ParserFilter[]; + removeNodeFilter: (name: string, callback?: ParserFilterCallback) => void; + parse: (html: string, args?: ParserArgs) => AstNode; +} +interface StyleSheetLoaderSettings { + maxLoadTime?: number; + contentCssCors?: boolean; + referrerPolicy?: ReferrerPolicy; +} +interface StyleSheetLoader { + load: (url: string) => Promise; + loadRawCss: (key: string, css: string) => void; + loadAll: (urls: string[]) => Promise; + unload: (url: string) => void; + unloadRawCss: (key: string) => void; + unloadAll: (urls: string[]) => void; + _setReferrerPolicy: (referrerPolicy: ReferrerPolicy) => void; + _setContentCssCors: (contentCssCors: boolean) => void; +} +type Registry = Registry$1; +interface EditorUiApi { + show: () => void; + hide: () => void; + setEnabled: (state: boolean) => void; + isEnabled: () => boolean; +} +interface EditorUi extends EditorUiApi { + registry: Registry; + styleSheetLoader: StyleSheetLoader; +} +type Ui_d_Registry = Registry; +type Ui_d_EditorUiApi = EditorUiApi; +type Ui_d_EditorUi = EditorUi; +declare namespace Ui_d { + export { Ui_d_Registry as Registry, PublicDialog_d as Dialog, PublicInlineContent_d as InlineContent, PublicMenu_d as Menu, PublicView_d as View, PublicSidebar_d as Sidebar, PublicToolbar_d as Toolbar, Ui_d_EditorUiApi as EditorUiApi, Ui_d_EditorUi as EditorUi, }; +} +interface WindowParams { + readonly inline?: 'cursor' | 'toolbar' | 'bottom'; + readonly ariaAttrs?: boolean; + readonly persistent?: boolean; +} +type InstanceApi = UrlDialogInstanceApi | DialogInstanceApi; +interface WindowManagerImpl { + open: (config: DialogSpec, params: WindowParams | undefined, closeWindow: (dialog: DialogInstanceApi) => void) => DialogInstanceApi; + openUrl: (config: UrlDialogSpec, closeWindow: (dialog: UrlDialogInstanceApi) => void) => UrlDialogInstanceApi; + alert: (message: string, callback: () => void) => void; + confirm: (message: string, callback: (state: boolean) => void) => void; + close: (dialog: InstanceApi) => void; +} +interface WindowManager { + open: (config: DialogSpec, params?: WindowParams) => DialogInstanceApi; + openUrl: (config: UrlDialogSpec) => UrlDialogInstanceApi; + alert: (message: string, callback?: () => void, scope?: any) => void; + confirm: (message: string, callback?: (state: boolean) => void, scope?: any) => void; + close: () => void; +} +interface ExecCommandEvent { + command: string; + ui: boolean; + value?: any; +} +interface BeforeGetContentEvent extends GetContentArgs { + selection?: boolean; +} +interface GetContentEvent extends BeforeGetContentEvent { + content: string; +} +interface BeforeSetContentEvent extends SetContentArgs { + content: string; + selection?: boolean; +} +interface SetContentEvent extends BeforeSetContentEvent { + content: string; +} +interface SaveContentEvent extends GetContentEvent { + save: boolean; +} +interface NewBlockEvent { + newBlock: Element; +} +interface NodeChangeEvent { + element: Element; + parents: Node[]; + selectionChange?: boolean; + initial?: boolean; +} +interface FormatEvent { + format: string; + vars?: FormatVars; + node?: Node | RangeLikeObject | null; +} +interface ObjectResizeEvent { + target: HTMLElement; + width: number; + height: number; + origin: string; +} +interface ObjectSelectedEvent { + target: Node; + targetClone?: Node; +} +interface ScrollIntoViewEvent { + elm: HTMLElement; + alignToTop: boolean | undefined; +} +interface SetSelectionRangeEvent { + range: Range; + forward: boolean | undefined; +} +interface ShowCaretEvent { + target: Node; + direction: number; + before: boolean; +} +interface SwitchModeEvent { + mode: string; +} +interface ChangeEvent { + level: UndoLevel; + lastLevel: UndoLevel | undefined; +} +interface AddUndoEvent extends ChangeEvent { + originalEvent: Event | undefined; +} +interface UndoRedoEvent { + level: UndoLevel; +} +interface WindowEvent { + dialog: InstanceApi; +} +interface ProgressStateEvent { + state: boolean; + time?: number; +} +interface AfterProgressStateEvent { + state: boolean; +} +interface PlaceholderToggleEvent { + state: boolean; +} +interface LoadErrorEvent { + message: string; +} +interface PreProcessEvent extends ParserArgs { + node: Element; +} +interface PostProcessEvent extends ParserArgs { + content: string; +} +interface PastePlainTextToggleEvent { + state: boolean; +} +interface PastePreProcessEvent { + content: string; + readonly internal: boolean; +} +interface PastePostProcessEvent { + node: HTMLElement; + readonly internal: boolean; +} +interface EditableRootStateChangeEvent { + state: boolean; +} +interface NewTableRowEvent { + node: HTMLTableRowElement; +} +interface NewTableCellEvent { + node: HTMLTableCellElement; +} +interface TableEventData { + readonly structure: boolean; + readonly style: boolean; +} +interface TableModifiedEvent extends TableEventData { + readonly table: HTMLTableElement; +} +interface BeforeOpenNotificationEvent { + notification: NotificationSpec; +} +interface OpenNotificationEvent { + notification: NotificationApi; +} +interface EditorEventMap extends Omit { + 'activate': { + relatedTarget: Editor | null; + }; + 'deactivate': { + relatedTarget: Editor; + }; + 'focus': { + blurredEditor: Editor | null; + }; + 'blur': { + focusedEditor: Editor | null; + }; + 'resize': UIEvent; + 'scroll': UIEvent; + 'input': InputEvent; + 'beforeinput': InputEvent; + 'detach': {}; + 'remove': {}; + 'init': {}; + 'ScrollIntoView': ScrollIntoViewEvent; + 'AfterScrollIntoView': ScrollIntoViewEvent; + 'ObjectResized': ObjectResizeEvent; + 'ObjectResizeStart': ObjectResizeEvent; + 'SwitchMode': SwitchModeEvent; + 'ScrollWindow': Event; + 'ResizeWindow': UIEvent; + 'SkinLoaded': {}; + 'SkinLoadError': LoadErrorEvent; + 'PluginLoadError': LoadErrorEvent; + 'ModelLoadError': LoadErrorEvent; + 'IconsLoadError': LoadErrorEvent; + 'ThemeLoadError': LoadErrorEvent; + 'LanguageLoadError': LoadErrorEvent; + 'BeforeExecCommand': ExecCommandEvent; + 'ExecCommand': ExecCommandEvent; + 'NodeChange': NodeChangeEvent; + 'FormatApply': FormatEvent; + 'FormatRemove': FormatEvent; + 'ShowCaret': ShowCaretEvent; + 'SelectionChange': {}; + 'ObjectSelected': ObjectSelectedEvent; + 'BeforeObjectSelected': ObjectSelectedEvent; + 'GetSelectionRange': { + range: Range; + }; + 'SetSelectionRange': SetSelectionRangeEvent; + 'AfterSetSelectionRange': SetSelectionRangeEvent; + 'BeforeGetContent': BeforeGetContentEvent; + 'GetContent': GetContentEvent; + 'BeforeSetContent': BeforeSetContentEvent; + 'SetContent': SetContentEvent; + 'SaveContent': SaveContentEvent; + 'RawSaveContent': SaveContentEvent; + 'LoadContent': { + load: boolean; + element: HTMLElement; + }; + 'PreviewFormats': {}; + 'AfterPreviewFormats': {}; + 'ScriptsLoaded': {}; + 'PreInit': {}; + 'PostRender': {}; + 'NewBlock': NewBlockEvent; + 'ClearUndos': {}; + 'TypingUndo': {}; + 'Redo': UndoRedoEvent; + 'Undo': UndoRedoEvent; + 'BeforeAddUndo': AddUndoEvent; + 'AddUndo': AddUndoEvent; + 'change': ChangeEvent; + 'CloseWindow': WindowEvent; + 'OpenWindow': WindowEvent; + 'ProgressState': ProgressStateEvent; + 'AfterProgressState': AfterProgressStateEvent; + 'PlaceholderToggle': PlaceholderToggleEvent; + 'tap': TouchEvent; + 'longpress': TouchEvent; + 'longpresscancel': {}; + 'PreProcess': PreProcessEvent; + 'PostProcess': PostProcessEvent; + 'AutocompleterStart': AutocompleterEventArgs; + 'AutocompleterUpdate': AutocompleterEventArgs; + 'AutocompleterEnd': {}; + 'PastePlainTextToggle': PastePlainTextToggleEvent; + 'PastePreProcess': PastePreProcessEvent; + 'PastePostProcess': PastePostProcessEvent; + 'TableModified': TableModifiedEvent; + 'NewRow': NewTableRowEvent; + 'NewCell': NewTableCellEvent; + 'SetAttrib': SetAttribEvent; + 'hide': {}; + 'show': {}; + 'dirty': {}; + 'BeforeOpenNotification': BeforeOpenNotificationEvent; + 'OpenNotification': OpenNotificationEvent; +} +interface EditorManagerEventMap { + 'AddEditor': { + editor: Editor; + }; + 'RemoveEditor': { + editor: Editor; + }; + 'BeforeUnload': { + returnValue: any; + }; +} +type EventTypes_d_ExecCommandEvent = ExecCommandEvent; +type EventTypes_d_BeforeGetContentEvent = BeforeGetContentEvent; +type EventTypes_d_GetContentEvent = GetContentEvent; +type EventTypes_d_BeforeSetContentEvent = BeforeSetContentEvent; +type EventTypes_d_SetContentEvent = SetContentEvent; +type EventTypes_d_SaveContentEvent = SaveContentEvent; +type EventTypes_d_NewBlockEvent = NewBlockEvent; +type EventTypes_d_NodeChangeEvent = NodeChangeEvent; +type EventTypes_d_FormatEvent = FormatEvent; +type EventTypes_d_ObjectResizeEvent = ObjectResizeEvent; +type EventTypes_d_ObjectSelectedEvent = ObjectSelectedEvent; +type EventTypes_d_ScrollIntoViewEvent = ScrollIntoViewEvent; +type EventTypes_d_SetSelectionRangeEvent = SetSelectionRangeEvent; +type EventTypes_d_ShowCaretEvent = ShowCaretEvent; +type EventTypes_d_SwitchModeEvent = SwitchModeEvent; +type EventTypes_d_ChangeEvent = ChangeEvent; +type EventTypes_d_AddUndoEvent = AddUndoEvent; +type EventTypes_d_UndoRedoEvent = UndoRedoEvent; +type EventTypes_d_WindowEvent = WindowEvent; +type EventTypes_d_ProgressStateEvent = ProgressStateEvent; +type EventTypes_d_AfterProgressStateEvent = AfterProgressStateEvent; +type EventTypes_d_PlaceholderToggleEvent = PlaceholderToggleEvent; +type EventTypes_d_LoadErrorEvent = LoadErrorEvent; +type EventTypes_d_PreProcessEvent = PreProcessEvent; +type EventTypes_d_PostProcessEvent = PostProcessEvent; +type EventTypes_d_PastePlainTextToggleEvent = PastePlainTextToggleEvent; +type EventTypes_d_PastePreProcessEvent = PastePreProcessEvent; +type EventTypes_d_PastePostProcessEvent = PastePostProcessEvent; +type EventTypes_d_EditableRootStateChangeEvent = EditableRootStateChangeEvent; +type EventTypes_d_NewTableRowEvent = NewTableRowEvent; +type EventTypes_d_NewTableCellEvent = NewTableCellEvent; +type EventTypes_d_TableEventData = TableEventData; +type EventTypes_d_TableModifiedEvent = TableModifiedEvent; +type EventTypes_d_BeforeOpenNotificationEvent = BeforeOpenNotificationEvent; +type EventTypes_d_OpenNotificationEvent = OpenNotificationEvent; +type EventTypes_d_EditorEventMap = EditorEventMap; +type EventTypes_d_EditorManagerEventMap = EditorManagerEventMap; +declare namespace EventTypes_d { + export { EventTypes_d_ExecCommandEvent as ExecCommandEvent, EventTypes_d_BeforeGetContentEvent as BeforeGetContentEvent, EventTypes_d_GetContentEvent as GetContentEvent, EventTypes_d_BeforeSetContentEvent as BeforeSetContentEvent, EventTypes_d_SetContentEvent as SetContentEvent, EventTypes_d_SaveContentEvent as SaveContentEvent, EventTypes_d_NewBlockEvent as NewBlockEvent, EventTypes_d_NodeChangeEvent as NodeChangeEvent, EventTypes_d_FormatEvent as FormatEvent, EventTypes_d_ObjectResizeEvent as ObjectResizeEvent, EventTypes_d_ObjectSelectedEvent as ObjectSelectedEvent, EventTypes_d_ScrollIntoViewEvent as ScrollIntoViewEvent, EventTypes_d_SetSelectionRangeEvent as SetSelectionRangeEvent, EventTypes_d_ShowCaretEvent as ShowCaretEvent, EventTypes_d_SwitchModeEvent as SwitchModeEvent, EventTypes_d_ChangeEvent as ChangeEvent, EventTypes_d_AddUndoEvent as AddUndoEvent, EventTypes_d_UndoRedoEvent as UndoRedoEvent, EventTypes_d_WindowEvent as WindowEvent, EventTypes_d_ProgressStateEvent as ProgressStateEvent, EventTypes_d_AfterProgressStateEvent as AfterProgressStateEvent, EventTypes_d_PlaceholderToggleEvent as PlaceholderToggleEvent, EventTypes_d_LoadErrorEvent as LoadErrorEvent, EventTypes_d_PreProcessEvent as PreProcessEvent, EventTypes_d_PostProcessEvent as PostProcessEvent, EventTypes_d_PastePlainTextToggleEvent as PastePlainTextToggleEvent, EventTypes_d_PastePreProcessEvent as PastePreProcessEvent, EventTypes_d_PastePostProcessEvent as PastePostProcessEvent, EventTypes_d_EditableRootStateChangeEvent as EditableRootStateChangeEvent, EventTypes_d_NewTableRowEvent as NewTableRowEvent, EventTypes_d_NewTableCellEvent as NewTableCellEvent, EventTypes_d_TableEventData as TableEventData, EventTypes_d_TableModifiedEvent as TableModifiedEvent, EventTypes_d_BeforeOpenNotificationEvent as BeforeOpenNotificationEvent, EventTypes_d_OpenNotificationEvent as OpenNotificationEvent, EventTypes_d_EditorEventMap as EditorEventMap, EventTypes_d_EditorManagerEventMap as EditorManagerEventMap, }; +} +type Format_d_Formats = Formats; +type Format_d_Format = Format; +type Format_d_ApplyFormat = ApplyFormat; +type Format_d_BlockFormat = BlockFormat; +type Format_d_InlineFormat = InlineFormat; +type Format_d_SelectorFormat = SelectorFormat; +type Format_d_RemoveFormat = RemoveFormat; +type Format_d_RemoveBlockFormat = RemoveBlockFormat; +type Format_d_RemoveInlineFormat = RemoveInlineFormat; +type Format_d_RemoveSelectorFormat = RemoveSelectorFormat; +declare namespace Format_d { + export { Format_d_Formats as Formats, Format_d_Format as Format, Format_d_ApplyFormat as ApplyFormat, Format_d_BlockFormat as BlockFormat, Format_d_InlineFormat as InlineFormat, Format_d_SelectorFormat as SelectorFormat, Format_d_RemoveFormat as RemoveFormat, Format_d_RemoveBlockFormat as RemoveBlockFormat, Format_d_RemoveInlineFormat as RemoveInlineFormat, Format_d_RemoveSelectorFormat as RemoveSelectorFormat, }; +} +type StyleFormat = BlockStyleFormat | InlineStyleFormat | SelectorStyleFormat; +type AllowedFormat = Separator | FormatReference | StyleFormat | NestedFormatting; +interface Separator { + title: string; +} +interface FormatReference { + title: string; + format: string; + icon?: string; +} +interface NestedFormatting { + title: string; + items: Array; +} +interface CommonStyleFormat { + name?: string; + title: string; + icon?: string; +} +interface BlockStyleFormat extends BlockFormat, CommonStyleFormat { +} +interface InlineStyleFormat extends InlineFormat, CommonStyleFormat { +} +interface SelectorStyleFormat extends SelectorFormat, CommonStyleFormat { +} +type EntityEncoding = 'named' | 'numeric' | 'raw' | 'named,numeric' | 'named+numeric' | 'numeric,named' | 'numeric+named'; +interface ContentLanguage { + readonly title: string; + readonly code: string; + readonly customCode?: string; +} +type ThemeInitFunc = (editor: Editor, elm: HTMLElement) => { + editorContainer: HTMLElement; + iframeContainer: HTMLElement; + height?: number; + iframeHeight?: number; + api?: EditorUiApi; +}; +type SetupCallback = (editor: Editor) => void; +type FilePickerCallback = (callback: (value: string, meta?: Record) => void, value: string, meta: Record) => void; +type FilePickerValidationStatus = 'valid' | 'unknown' | 'invalid' | 'none'; +type FilePickerValidationCallback = (info: { + type: string; + url: string; +}, callback: (validation: { + status: FilePickerValidationStatus; + message: string; +}) => void) => void; +type PastePreProcessFn = (editor: Editor, args: PastePreProcessEvent) => void; +type PastePostProcessFn = (editor: Editor, args: PastePostProcessEvent) => void; +type URLConverter = (url: string, name: string, elm?: string | Element) => string; +type URLConverterCallback = (url: string, node: Node | string | undefined, on_save: boolean, name: string) => string; +interface ToolbarGroup { + name?: string; + items: string[]; +} +type ToolbarMode = 'floating' | 'sliding' | 'scrolling' | 'wrap'; +type ToolbarLocation = 'top' | 'bottom' | 'auto'; +interface BaseEditorOptions { + a11y_advanced_options?: boolean; + add_form_submit_trigger?: boolean; + add_unload_trigger?: boolean; + allow_conditional_comments?: boolean; + allow_html_data_urls?: boolean; + allow_html_in_named_anchor?: boolean; + allow_script_urls?: boolean; + allow_svg_data_urls?: boolean; + allow_unsafe_link_target?: boolean; + anchor_bottom?: false | string; + anchor_top?: false | string; + auto_focus?: string | true; + automatic_uploads?: boolean; + base_url?: string; + block_formats?: string; + block_unsupported_drop?: boolean; + body_id?: string; + body_class?: string; + br_in_pre?: boolean; + br_newline_selector?: string; + browser_spellcheck?: boolean; + branding?: boolean; + cache_suffix?: string; + color_cols?: number; + color_cols_foreground?: number; + color_cols_background?: number; + color_map?: string[]; + color_map_foreground?: string[]; + color_map_background?: string[]; + color_default_foreground?: string; + color_default_background?: string; + content_css?: boolean | string | string[]; + content_css_cors?: boolean; + content_security_policy?: string; + content_style?: string; + content_langs?: ContentLanguage[]; + contextmenu?: string | string[] | false; + contextmenu_never_use_native?: boolean; + convert_fonts_to_spans?: boolean; + convert_unsafe_embeds?: boolean; + convert_urls?: boolean; + custom_colors?: boolean; + custom_elements?: string | Record; + custom_ui_selector?: string; + custom_undo_redo_levels?: number; + default_font_stack?: string[]; + deprecation_warnings?: boolean; + directionality?: 'ltr' | 'rtl'; + doctype?: string; + document_base_url?: string; + draggable_modal?: boolean; + editable_class?: string; + editable_root?: boolean; + element_format?: 'xhtml' | 'html'; + elementpath?: boolean; + encoding?: string; + end_container_on_empty_block?: boolean | string; + entities?: string; + entity_encoding?: EntityEncoding; + extended_valid_elements?: string; + event_root?: string; + file_picker_callback?: FilePickerCallback; + file_picker_types?: string; + file_picker_validator_handler?: FilePickerValidationCallback; + fix_list_elements?: boolean; + fixed_toolbar_container?: string; + fixed_toolbar_container_target?: HTMLElement; + font_css?: string | string[]; + font_family_formats?: string; + font_size_classes?: string; + font_size_legacy_values?: string; + font_size_style_values?: string; + font_size_formats?: string; + font_size_input_default_unit?: string; + forced_root_block?: string; + forced_root_block_attrs?: Record; + formats?: Formats; + format_noneditable_selector?: string; + height?: number | string; + help_accessibility?: boolean; + hidden_input?: boolean; + highlight_on_focus?: boolean; + icons?: string; + icons_url?: string; + id?: string; + iframe_aria_text?: string; + iframe_attrs?: Record; + images_file_types?: string; + images_replace_blob_uris?: boolean; + images_reuse_filename?: boolean; + images_upload_base_path?: string; + images_upload_credentials?: boolean; + images_upload_handler?: UploadHandler; + images_upload_url?: string; + indent?: boolean; + indent_after?: string; + indent_before?: string; + indent_use_margin?: boolean; + indentation?: string; + init_instance_callback?: SetupCallback; + inline?: boolean; + inline_boundaries?: boolean; + inline_boundaries_selector?: string; + inline_styles?: boolean; + invalid_elements?: string; + invalid_styles?: string | Record; + keep_styles?: boolean; + language?: string; + language_load?: boolean; + language_url?: string; + line_height_formats?: string; + max_height?: number; + max_width?: number; + menu?: Record; + menubar?: boolean | string; + min_height?: number; + min_width?: number; + model?: string; + model_url?: string; + newdocument_content?: string; + newline_behavior?: 'block' | 'linebreak' | 'invert' | 'default'; + no_newline_selector?: string; + noneditable_class?: string; + noneditable_regexp?: RegExp | RegExp[]; + nowrap?: boolean; + object_resizing?: boolean | string; + pad_empty_with_br?: boolean; + paste_as_text?: boolean; + paste_block_drop?: boolean; + paste_data_images?: boolean; + paste_merge_formats?: boolean; + paste_postprocess?: PastePostProcessFn; + paste_preprocess?: PastePreProcessFn; + paste_remove_styles_if_webkit?: boolean; + paste_tab_spaces?: number; + paste_webkit_styles?: string; + placeholder?: string; + preserve_cdata?: boolean; + preview_styles?: false | string; + promotion?: boolean; + protect?: RegExp[]; + readonly?: boolean; + referrer_policy?: ReferrerPolicy; + relative_urls?: boolean; + remove_script_host?: boolean; + remove_trailing_brs?: boolean; + removed_menuitems?: string; + resize?: boolean | 'both'; + resize_img_proportional?: boolean; + root_name?: string; + sandbox_iframes?: boolean; + sandbox_iframes_exclusions?: string[]; + schema?: SchemaType; + selector?: string; + setup?: SetupCallback; + sidebar_show?: string; + skin?: boolean | string; + skin_url?: string; + smart_paste?: boolean; + statusbar?: boolean; + style_formats?: AllowedFormat[]; + style_formats_autohide?: boolean; + style_formats_merge?: boolean; + submit_patch?: boolean; + suffix?: string; + table_tab_navigation?: boolean; + target?: HTMLElement; + text_patterns?: RawPattern[] | false; + text_patterns_lookup?: RawDynamicPatternsLookup; + theme?: string | ThemeInitFunc | false; + theme_url?: string; + toolbar?: boolean | string | string[] | Array; + toolbar1?: string; + toolbar2?: string; + toolbar3?: string; + toolbar4?: string; + toolbar5?: string; + toolbar6?: string; + toolbar7?: string; + toolbar8?: string; + toolbar9?: string; + toolbar_groups?: Record; + toolbar_location?: ToolbarLocation; + toolbar_mode?: ToolbarMode; + toolbar_sticky?: boolean; + toolbar_sticky_offset?: number; + typeahead_urls?: boolean; + ui_mode?: 'combined' | 'split'; + url_converter?: URLConverter; + url_converter_scope?: any; + urlconverter_callback?: URLConverterCallback; + valid_children?: string; + valid_classes?: string | Record; + valid_elements?: string; + valid_styles?: string | Record; + verify_html?: boolean; + visual?: boolean; + visual_anchor_class?: string; + visual_table_class?: string; + width?: number | string; + xss_sanitization?: boolean; + license_key?: string; + disable_nodechange?: boolean; + forced_plugins?: string | string[]; + plugin_base_urls?: Record; + service_message?: string; + [key: string]: any; +} +interface RawEditorOptions extends BaseEditorOptions { + external_plugins?: Record; + mobile?: RawEditorOptions; + plugins?: string | string[]; +} +interface NormalizedEditorOptions extends BaseEditorOptions { + external_plugins: Record; + forced_plugins: string[]; + plugins: string[]; +} +interface EditorOptions extends NormalizedEditorOptions { + a11y_advanced_options: boolean; + allow_unsafe_link_target: boolean; + anchor_bottom: string; + anchor_top: string; + automatic_uploads: boolean; + block_formats: string; + body_class: string; + body_id: string; + br_newline_selector: string; + color_map: string[]; + color_cols: number; + color_cols_foreground: number; + color_cols_background: number; + color_default_background: string; + color_default_foreground: string; + content_css: string[]; + contextmenu: string[]; + convert_unsafe_embeds: boolean; + custom_colors: boolean; + default_font_stack: string[]; + document_base_url: string; + init_content_sync: boolean; + draggable_modal: boolean; + editable_class: string; + editable_root: boolean; + font_css: string[]; + font_family_formats: string; + font_size_classes: string; + font_size_formats: string; + font_size_input_default_unit: string; + font_size_legacy_values: string; + font_size_style_values: string; + forced_root_block: string; + forced_root_block_attrs: Record; + format_noneditable_selector: string; + height: number | string; + highlight_on_focus: boolean; + iframe_attrs: Record; + images_file_types: string; + images_upload_base_path: string; + images_upload_credentials: boolean; + images_upload_url: string; + indent_use_margin: boolean; + indentation: string; + inline: boolean; + inline_boundaries_selector: string; + language: string; + language_load: boolean; + language_url: string; + line_height_formats: string; + menu: Record; + menubar: boolean | string; + model: string; + newdocument_content: string; + no_newline_selector: string; + noneditable_class: string; + noneditable_regexp: RegExp[]; + object_resizing: string; + pad_empty_with_br: boolean; + paste_as_text: boolean; + preview_styles: string; + promotion: boolean; + readonly: boolean; + removed_menuitems: string; + sandbox_iframes: boolean; + sandbox_iframes_exclusions: string[]; + toolbar: boolean | string | string[] | Array; + toolbar_groups: Record; + toolbar_location: ToolbarLocation; + toolbar_mode: ToolbarMode; + toolbar_persist: boolean; + toolbar_sticky: boolean; + toolbar_sticky_offset: number; + text_patterns: Pattern[]; + text_patterns_lookup: DynamicPatternsLookup; + visual: boolean; + visual_anchor_class: string; + visual_table_class: string; + width: number | string; + xss_sanitization: boolean; +} +type StyleMap = Record; +interface StylesSettings { + allow_script_urls?: boolean; + allow_svg_data_urls?: boolean; + url_converter?: URLConverter; + url_converter_scope?: any; +} +interface Styles { + parse: (css: string | undefined) => Record; + serialize: (styles: StyleMap, elementName?: string) => string; +} +type EventUtilsCallback = (event: EventUtilsEvent) => void | boolean; +type EventUtilsEvent = NormalizedEvent & { + metaKey: boolean; +}; +interface Callback$1 { + func: EventUtilsCallback; + scope: any; +} +interface CallbackList extends Array> { + fakeName: string | false; + capture: boolean; + nativeHandler: EventListener; +} +interface EventUtilsConstructor { + readonly prototype: EventUtils; + new (): EventUtils; + Event: EventUtils; +} +declare class EventUtils { + static Event: EventUtils; + domLoaded: boolean; + events: Record>>; + private readonly expando; + private hasFocusIn; + private count; + constructor(); + bind(target: any, name: K, callback: EventUtilsCallback, scope?: any): EventUtilsCallback; + bind(target: any, names: string, callback: EventUtilsCallback, scope?: any): EventUtilsCallback; + unbind(target: any, name: K, callback?: EventUtilsCallback): this; + unbind(target: any, names: string, callback?: EventUtilsCallback): this; + unbind(target: any): this; + fire(target: any, name: string, args?: {}): this; + dispatch(target: any, name: string, args?: {}): this; + clean(target: any): this; + destroy(): void; + cancel(e: EventUtilsEvent): boolean; + private executeHandlers; +} +interface SetAttribEvent { + attrElm: HTMLElement; + attrName: string; + attrValue: string | boolean | number | null; +} +interface DOMUtilsSettings { + schema: Schema; + url_converter: URLConverter; + url_converter_scope: any; + ownEvents: boolean; + keep_values: boolean; + update_styles: boolean; + root_element: HTMLElement | null; + collect: boolean; + onSetAttrib: (event: SetAttribEvent) => void; + contentCssCors: boolean; + referrerPolicy: ReferrerPolicy; +} +type Target = Node | Window; +type RunArguments = string | T | Array | null; +type BoundEvent = [ + Target, + string, + EventUtilsCallback, + any +]; +type Callback = EventUtilsCallback>; +type RunResult = T extends Array ? R[] : false | R; +interface DOMUtils { + doc: Document; + settings: Partial; + win: Window; + files: Record; + stdMode: boolean; + boxModel: boolean; + styleSheetLoader: StyleSheetLoader; + boundEvents: BoundEvent[]; + styles: Styles; + schema: Schema; + events: EventUtils; + root: Node | null; + isBlock: { + (node: Node | null): node is HTMLElement; + (node: string): boolean; + }; + clone: (node: Node, deep: boolean) => Node; + getRoot: () => HTMLElement; + getViewPort: (argWin?: Window) => GeomRect; + getRect: (elm: string | HTMLElement) => GeomRect; + getSize: (elm: string | HTMLElement) => { + w: number; + h: number; + }; + getParent: { + (node: string | Node | null, selector: K, root?: Node): HTMLElementTagNameMap[K] | null; + (node: string | Node | null, selector: string | ((node: Node) => node is T), root?: Node): T | null; + (node: string | Node | null, selector?: string | ((node: Node) => boolean | void), root?: Node): Node | null; + }; + getParents: { + (elm: string | HTMLElementTagNameMap[K] | null, selector: K, root?: Node, collect?: boolean): Array; + (node: string | Node | null, selector: string | ((node: Node) => node is T), root?: Node, collect?: boolean): T[]; + (elm: string | Node | null, selector?: string | ((node: Node) => boolean | void), root?: Node, collect?: boolean): Node[]; + }; + get: { + (elm: T): T; + (elm: string): HTMLElement | null; + }; + getNext: (node: Node | null, selector: string | ((node: Node) => boolean)) => Node | null; + getPrev: (node: Node | null, selector: string | ((node: Node) => boolean)) => Node | null; + select: { + (selector: K, scope?: string | Node): Array; + (selector: string, scope?: string | Node): T[]; + }; + is: { + (elm: Node | Node[] | null, selector: string): elm is T; + (elm: Node | Node[] | null, selector: string): boolean; + }; + add: (parentElm: RunArguments, name: string | Element, attrs?: Record, html?: string | Node | null, create?: boolean) => HTMLElement; + create: { + (name: K, attrs?: Record, html?: string | Node | null): HTMLElementTagNameMap[K]; + (name: string, attrs?: Record, html?: string | Node | null): HTMLElement; + }; + createHTML: (name: string, attrs?: Record, html?: string) => string; + createFragment: (html?: string) => DocumentFragment; + remove: { + (node: T | T[], keepChildren?: boolean): typeof node extends Array ? T[] : T; + (node: string, keepChildren?: boolean): T | false; + }; + getStyle: { + (elm: Element, name: string, computed: true): string; + (elm: string | Element | null, name: string, computed?: boolean): string | undefined; + }; + setStyle: (elm: string | Element | Element[], name: string, value: string | number | null) => void; + setStyles: (elm: string | Element | Element[], stylesArg: StyleMap) => void; + removeAllAttribs: (e: RunArguments) => void; + setAttrib: (elm: RunArguments, name: string, value: string | boolean | number | null) => void; + setAttribs: (elm: RunArguments, attrs: Record) => void; + getAttrib: (elm: string | Element | null, name: string, defaultVal?: string) => string; + getAttribs: (elm: string | Element) => NamedNodeMap | Attr[]; + getPos: (elm: string | Element, rootElm?: Node) => { + x: number; + y: number; + }; + parseStyle: (cssText: string) => Record; + serializeStyle: (stylesArg: StyleMap, name?: string) => string; + addStyle: (cssText: string) => void; + loadCSS: (url: string) => void; + hasClass: (elm: string | Element, cls: string) => boolean; + addClass: (elm: RunArguments, cls: string) => void; + removeClass: (elm: RunArguments, cls: string) => void; + toggleClass: (elm: RunArguments, cls: string, state?: boolean) => void; + show: (elm: string | Node | Node[]) => void; + hide: (elm: string | Node | Node[]) => void; + isHidden: (elm: string | Node) => boolean; + uniqueId: (prefix?: string) => string; + setHTML: (elm: RunArguments, html: string) => void; + getOuterHTML: (elm: string | Node) => string; + setOuterHTML: (elm: string | Node | Node[], html: string) => void; + decode: (text: string) => string; + encode: (text: string) => string; + insertAfter: { + (node: T | T[], reference: string | Node): T; + (node: RunArguments, reference: string | Node): RunResult; + }; + replace: { + (newElm: Node, oldElm: T | T[], keepChildren?: boolean): T; + (newElm: Node, oldElm: RunArguments, keepChildren?: boolean): false | T; + }; + rename: { + (elm: Element, name: K): HTMLElementTagNameMap[K]; + (elm: Element, name: string): Element; + }; + findCommonAncestor: (a: Node, b: Node) => Node | null; + run(this: DOMUtils, elm: T | T[], func: (node: T) => R, scope?: any): typeof elm extends Array ? R[] : R; + run(this: DOMUtils, elm: RunArguments, func: (node: T) => R, scope?: any): RunResult; + isEmpty: (node: Node, elements?: Record, options?: IsEmptyOptions) => boolean; + createRng: () => Range; + nodeIndex: (node: Node, normalized?: boolean) => number; + split: { + (parentElm: Node, splitElm: Node, replacementElm: T): T | undefined; + (parentElm: Node, splitElm: T): T | undefined; + }; + bind: { + (target: Target, name: K, func: Callback, scope?: any): Callback; + (target: Target[], name: K, func: Callback, scope?: any): Callback[]; + }; + unbind: { + (target: Target, name?: K, func?: EventUtilsCallback>): EventUtils; + (target: Target[], name?: K, func?: EventUtilsCallback>): EventUtils[]; + }; + fire: (target: Node | Window, name: string, evt?: {}) => EventUtils; + dispatch: (target: Node | Window, name: string, evt?: {}) => EventUtils; + getContentEditable: (node: Node) => string | null; + getContentEditableParent: (node: Node) => string | null; + isEditable: (node: Node | null | undefined) => boolean; + destroy: () => void; + isChildOf: (node: Node, parent: Node) => boolean; + dumpRng: (r: Range) => string; +} +interface ClientRect { + left: number; + top: number; + bottom: number; + right: number; + width: number; + height: number; +} +interface BookmarkManager { + getBookmark: (type?: number, normalized?: boolean) => Bookmark; + moveToBookmark: (bookmark: Bookmark) => void; +} +interface ControlSelection { + isResizable: (elm: Element) => boolean; + showResizeRect: (elm: HTMLElement) => void; + hideResizeRect: () => void; + updateResizeRect: (evt: EditorEvent) => void; + destroy: () => void; +} +interface WriterSettings { + element_format?: 'xhtml' | 'html'; + entities?: string; + entity_encoding?: EntityEncoding; + indent?: boolean; + indent_after?: string; + indent_before?: string; +} +type Attributes = Array<{ + name: string; + value: string; +}>; +interface Writer { + cdata: (text: string) => void; + comment: (text: string) => void; + doctype: (text: string) => void; + end: (name: string) => void; + getContent: () => string; + pi: (name: string, text?: string) => void; + reset: () => void; + start: (name: string, attrs?: Attributes | null, empty?: boolean) => void; + text: (text: string, raw?: boolean) => void; +} +interface HtmlSerializerSettings extends WriterSettings { + inner?: boolean; + validate?: boolean; +} +interface HtmlSerializer { + serialize: (node: AstNode) => string; +} +interface DomSerializerSettings extends DomParserSettings, WriterSettings, SchemaSettings, HtmlSerializerSettings { + remove_trailing_brs?: boolean; + url_converter?: URLConverter; + url_converter_scope?: {}; +} +interface DomSerializerImpl { + schema: Schema; + addNodeFilter: (name: string, callback: ParserFilterCallback) => void; + addAttributeFilter: (name: string, callback: ParserFilterCallback) => void; + getNodeFilters: () => ParserFilter[]; + getAttributeFilters: () => ParserFilter[]; + removeNodeFilter: (name: string, callback?: ParserFilterCallback) => void; + removeAttributeFilter: (name: string, callback?: ParserFilterCallback) => void; + serialize: { + (node: Element, parserArgs: { + format: 'tree'; + } & ParserArgs): AstNode; + (node: Element, parserArgs?: ParserArgs): string; + }; + addRules: (rules: string) => void; + setRules: (rules: string) => void; + addTempAttr: (name: string) => void; + getTempAttrs: () => string[]; +} +interface DomSerializer extends DomSerializerImpl { +} +interface EditorSelection { + bookmarkManager: BookmarkManager; + controlSelection: ControlSelection; + dom: DOMUtils; + win: Window; + serializer: DomSerializer; + editor: Editor; + collapse: (toStart?: boolean) => void; + setCursorLocation: { + (node: Node, offset: number): void; + (): void; + }; + getContent: { + (args: { + format: 'tree'; + } & Partial): AstNode; + (args?: Partial): string; + }; + setContent: (content: string, args?: Partial) => void; + getBookmark: (type?: number, normalized?: boolean) => Bookmark; + moveToBookmark: (bookmark: Bookmark) => void; + select: (node: Node, content?: boolean) => Node; + isCollapsed: () => boolean; + isEditable: () => boolean; + isForward: () => boolean; + setNode: (elm: Element) => Element; + getNode: () => HTMLElement; + getSel: () => Selection | null; + setRng: (rng: Range, forward?: boolean) => void; + getRng: () => Range; + getStart: (real?: boolean) => Element; + getEnd: (real?: boolean) => Element; + getSelectedBlocks: (startElm?: Element, endElm?: Element) => Element[]; + normalize: () => Range; + selectorChanged: (selector: string, callback: (active: boolean, args: { + node: Node; + selector: String; + parents: Node[]; + }) => void) => EditorSelection; + selectorChangedWithUnbind: (selector: string, callback: (active: boolean, args: { + node: Node; + selector: String; + parents: Node[]; + }) => void) => { + unbind: () => void; + }; + getScrollContainer: () => HTMLElement | undefined; + scrollIntoView: (elm?: HTMLElement, alignToTop?: boolean) => void; + placeCaretAt: (clientX: number, clientY: number) => void; + getBoundingClientRect: () => ClientRect | DOMRect; + destroy: () => void; + expand: (options?: { + type: 'word'; + }) => void; +} +type EditorCommandCallback = (this: S, ui: boolean, value: any) => void; +type EditorCommandsCallback = (command: string, ui: boolean, value?: any) => void; +interface Commands { + state: Record boolean>; + exec: Record; + value: Record string>; +} +interface ExecCommandArgs { + skip_focus?: boolean; +} +interface EditorCommandsConstructor { + readonly prototype: EditorCommands; + new (editor: Editor): EditorCommands; +} +declare class EditorCommands { + private readonly editor; + private commands; + constructor(editor: Editor); + execCommand(command: string, ui?: boolean, value?: any, args?: ExecCommandArgs): boolean; + queryCommandState(command: string): boolean; + queryCommandValue(command: string): string; + addCommands(commandList: Commands[K], type: K): void; + addCommands(commandList: Record): void; + addCommand(command: string, callback: EditorCommandCallback, scope: S): void; + addCommand(command: string, callback: EditorCommandCallback): void; + queryCommandSupported(command: string): boolean; + addQueryStateHandler(command: string, callback: (this: S) => boolean, scope: S): void; + addQueryStateHandler(command: string, callback: (this: Editor) => boolean): void; + addQueryValueHandler(command: string, callback: (this: S) => string, scope: S): void; + addQueryValueHandler(command: string, callback: (this: Editor) => string): void; +} +interface RawString { + raw: string; +} +type Primitive = string | number | boolean | Record | Function; +type TokenisedString = [ + string, + ...Primitive[] +]; +type Untranslated = Primitive | TokenisedString | RawString | null | undefined; +type TranslatedString = string; +interface I18n { + getData: () => Record>; + setCode: (newCode: string) => void; + getCode: () => string; + add: (code: string, items: Record) => void; + translate: (text: Untranslated) => TranslatedString; + isRtl: () => boolean; + hasCode: (code: string) => boolean; +} +interface Observable { + fire>(name: K, args?: U, bubble?: boolean): EditorEvent; + dispatch>(name: K, args?: U, bubble?: boolean): EditorEvent; + on(name: K, callback: (event: EditorEvent>) => void, prepend?: boolean): EventDispatcher; + off(name?: K, callback?: (event: EditorEvent>) => void): EventDispatcher; + once(name: K, callback: (event: EditorEvent>) => void): EventDispatcher; + hasEventListeners(name: string): boolean; +} +interface URISettings { + base_uri?: URI; +} +interface URIConstructor { + readonly prototype: URI; + new (url: string, settings?: URISettings): URI; + getDocumentBaseUrl: (loc: { + protocol: string; + host?: string; + href?: string; + pathname?: string; + }) => string; + parseDataUri: (uri: string) => { + type: string; + data: string; + }; +} +interface SafeUriOptions { + readonly allow_html_data_urls?: boolean; + readonly allow_script_urls?: boolean; + readonly allow_svg_data_urls?: boolean; +} +declare class URI { + static parseDataUri(uri: string): { + type: string | undefined; + data: string; + }; + static isDomSafe(uri: string, context?: string, options?: SafeUriOptions): boolean; + static getDocumentBaseUrl(loc: { + protocol: string; + host?: string; + href?: string; + pathname?: string; + }): string; + source: string; + protocol: string | undefined; + authority: string | undefined; + userInfo: string | undefined; + user: string | undefined; + password: string | undefined; + host: string | undefined; + port: string | undefined; + relative: string | undefined; + path: string; + directory: string; + file: string | undefined; + query: string | undefined; + anchor: string | undefined; + settings: URISettings; + constructor(url: string, settings?: URISettings); + setPath(path: string): void; + toRelative(uri: string): string; + toAbsolute(uri: string, noHost?: boolean): string; + isSameOrigin(uri: URI): boolean; + toRelPath(base: string, path: string): string; + toAbsPath(base: string, path: string): string; + getURI(noProtoHost?: boolean): string; +} +interface EditorManager extends Observable { + defaultOptions: RawEditorOptions; + majorVersion: string; + minorVersion: string; + releaseDate: string; + activeEditor: Editor | null; + focusedEditor: Editor | null; + baseURI: URI; + baseURL: string; + documentBaseURL: string; + i18n: I18n; + suffix: string; + add(this: EditorManager, editor: Editor): Editor; + addI18n: (code: string, item: Record) => void; + createEditor(this: EditorManager, id: string, options: RawEditorOptions): Editor; + execCommand(this: EditorManager, cmd: string, ui: boolean, value: any): boolean; + get(this: EditorManager): Editor[]; + get(this: EditorManager, id: number | string): Editor | null; + init(this: EditorManager, options: RawEditorOptions): Promise; + overrideDefaults(this: EditorManager, defaultOptions: Partial): void; + remove(this: EditorManager): void; + remove(this: EditorManager, selector: string): void; + remove(this: EditorManager, editor: Editor): Editor | null; + setActive(this: EditorManager, editor: Editor): void; + setup(this: EditorManager): void; + translate: (text: Untranslated) => TranslatedString; + triggerSave: () => void; + _setBaseUrl(this: EditorManager, baseUrl: string): void; +} +interface EditorObservable extends Observable { + bindPendingEventDelegates(this: Editor): void; + toggleNativeEvent(this: Editor, name: string, state: boolean): void; + unbindAllNativeEvents(this: Editor): void; +} +interface ProcessorSuccess { + valid: true; + value: T; +} +interface ProcessorError { + valid: false; + message: string; +} +type SimpleProcessor = (value: unknown) => boolean; +type Processor = (value: unknown) => ProcessorSuccess | ProcessorError; +interface BuiltInOptionTypeMap { + 'string': string; + 'number': number; + 'boolean': boolean; + 'array': any[]; + 'function': Function; + 'object': any; + 'string[]': string[]; + 'object[]': any[]; + 'regexp': RegExp; +} +type BuiltInOptionType = keyof BuiltInOptionTypeMap; +interface BaseOptionSpec { + immutable?: boolean; + deprecated?: boolean; + docsUrl?: string; +} +interface BuiltInOptionSpec extends BaseOptionSpec { + processor: K; + default?: BuiltInOptionTypeMap[K]; +} +interface SimpleOptionSpec extends BaseOptionSpec { + processor: SimpleProcessor; + default?: T; +} +interface OptionSpec extends BaseOptionSpec { + processor: Processor; + default?: T; +} +interface Options { + register: { + (name: string, spec: BuiltInOptionSpec): void; + (name: K, spec: OptionSpec | SimpleOptionSpec): void; + (name: string, spec: OptionSpec): void; + (name: string, spec: SimpleOptionSpec): void; + }; + isRegistered: (name: string) => boolean; + get: { + (name: K): EditorOptions[K]; + (name: string): T | undefined; + }; + set: (name: K, value: K extends keyof NormalizedEditorOptions ? NormalizedEditorOptions[K] : T) => boolean; + unset: (name: string) => boolean; + isSet: (name: string) => boolean; + debug: () => void; +} +interface UploadResult$1 { + element: HTMLImageElement; + status: boolean; + blobInfo: BlobInfo; + uploadUri: string; + removed: boolean; +} +interface EditorUpload { + blobCache: BlobCache; + addFilter: (filter: (img: HTMLImageElement) => boolean) => void; + uploadImages: () => Promise; + uploadImagesAuto: () => Promise; + scanForImages: () => Promise; + destroy: () => void; +} +type FormatChangeCallback = (state: boolean, data: { + node: Node; + format: string; + parents: Element[]; +}) => void; +interface FormatRegistry { + get: { + (name: string): Format[] | undefined; + (): Record; + }; + has: (name: string) => boolean; + register: (name: string | Formats, format?: Format[] | Format) => void; + unregister: (name: string) => Formats; +} +interface Formatter extends FormatRegistry { + apply: (name: string, vars?: FormatVars, node?: Node | RangeLikeObject | null) => void; + remove: (name: string, vars?: FormatVars, node?: Node | Range, similar?: boolean) => void; + toggle: (name: string, vars?: FormatVars, node?: Node) => void; + match: (name: string, vars?: FormatVars, node?: Node, similar?: boolean) => boolean; + closest: (names: string[]) => string | null; + matchAll: (names: string[], vars?: FormatVars) => string[]; + matchNode: (node: Node | null, name: string, vars?: FormatVars, similar?: boolean) => Format | undefined; + canApply: (name: string) => boolean; + formatChanged: (names: string, callback: FormatChangeCallback, similar?: boolean, vars?: FormatVars) => { + unbind: () => void; + }; + getCssText: (format: string | ApplyFormat) => string; +} +interface EditorMode { + isReadOnly: () => boolean; + set: (mode: string) => void; + get: () => string; + register: (mode: string, api: EditorModeApi) => void; +} +interface EditorModeApi { + activate: () => void; + deactivate: () => void; + editorReadOnly: boolean; +} +interface Model { + readonly table: { + readonly getSelectedCells: () => HTMLTableCellElement[]; + readonly clearSelectedCells: (container: Node) => void; + }; +} +type ModelManager = AddOnManager; +interface Plugin { + getMetadata?: () => { + name: string; + url: string; + }; + init?: (editor: Editor, url: string) => void; + [key: string]: any; +} +type PluginManager = AddOnManager; +interface ShortcutsConstructor { + readonly prototype: Shortcuts; + new (editor: Editor): Shortcuts; +} +type CommandFunc = string | [ + string, + boolean, + any +] | (() => void); +declare class Shortcuts { + private readonly editor; + private readonly shortcuts; + private pendingPatterns; + constructor(editor: Editor); + add(pattern: string, desc: string | null, cmdFunc: CommandFunc, scope?: any): boolean; + remove(pattern: string): boolean; + private normalizeCommandFunc; + private createShortcut; + private hasModifier; + private isFunctionKey; + private matchShortcut; + private executeShortcutAction; +} +interface RenderResult { + iframeContainer?: HTMLElement; + editorContainer: HTMLElement; + api?: Partial; +} +interface Theme { + ui?: any; + inline?: any; + execCommand?: (command: string, ui?: boolean, value?: any) => boolean; + destroy?: () => void; + init?: (editor: Editor, url: string) => void; + renderUI?: () => Promise | RenderResult; + getNotificationManagerImpl?: () => NotificationManagerImpl; + getWindowManagerImpl?: () => WindowManagerImpl; +} +type ThemeManager = AddOnManager; +interface EditorConstructor { + readonly prototype: Editor; + new (id: string, options: RawEditorOptions, editorManager: EditorManager): Editor; +} +declare class Editor implements EditorObservable { + documentBaseUrl: string; + baseUri: URI; + id: string; + plugins: Record; + documentBaseURI: URI; + baseURI: URI; + contentCSS: string[]; + contentStyles: string[]; + ui: EditorUi; + mode: EditorMode; + options: Options; + editorUpload: EditorUpload; + shortcuts: Shortcuts; + loadedCSS: Record; + editorCommands: EditorCommands; + suffix: string; + editorManager: EditorManager; + hidden: boolean; + inline: boolean; + hasVisual: boolean; + isNotDirty: boolean; + annotator: Annotator; + bodyElement: HTMLElement | undefined; + bookmark: any; + composing: boolean; + container: HTMLElement; + contentAreaContainer: HTMLElement; + contentDocument: Document; + contentWindow: Window; + delegates: Record> | undefined; + destroyed: boolean; + dom: DOMUtils; + editorContainer: HTMLElement; + eventRoot: Element | undefined; + formatter: Formatter; + formElement: HTMLElement | undefined; + formEventDelegate: ((e: Event) => void) | undefined; + hasHiddenInput: boolean; + iframeElement: HTMLIFrameElement | null; + iframeHTML: string | undefined; + initialized: boolean; + notificationManager: NotificationManager; + orgDisplay: string; + orgVisibility: string | undefined; + parser: DomParser; + quirks: Quirks; + readonly: boolean; + removed: boolean; + schema: Schema; + selection: EditorSelection; + serializer: DomSerializer; + startContent: string; + targetElm: HTMLElement; + theme: Theme; + model: Model; + undoManager: UndoManager; + windowManager: WindowManager; + _beforeUnload: (() => void) | undefined; + _eventDispatcher: EventDispatcher | undefined; + _nodeChangeDispatcher: NodeChange; + _pendingNativeEvents: string[]; + _selectionOverrides: SelectionOverrides; + _skinLoaded: boolean; + _editableRoot: boolean; + bindPendingEventDelegates: EditorObservable['bindPendingEventDelegates']; + toggleNativeEvent: EditorObservable['toggleNativeEvent']; + unbindAllNativeEvents: EditorObservable['unbindAllNativeEvents']; + fire: EditorObservable['fire']; + dispatch: EditorObservable['dispatch']; + on: EditorObservable['on']; + off: EditorObservable['off']; + once: EditorObservable['once']; + hasEventListeners: EditorObservable['hasEventListeners']; + constructor(id: string, options: RawEditorOptions, editorManager: EditorManager); + render(): void; + focus(skipFocus?: boolean): void; + hasFocus(): boolean; + translate(text: Untranslated): TranslatedString; + getParam(name: string, defaultVal: BuiltInOptionTypeMap[K], type: K): BuiltInOptionTypeMap[K]; + getParam(name: K, defaultVal?: NormalizedEditorOptions[K], type?: BuiltInOptionType): NormalizedEditorOptions[K]; + getParam(name: string, defaultVal: T, type?: BuiltInOptionType): T; + hasPlugin(name: string, loaded?: boolean): boolean; + nodeChanged(args?: any): void; + addCommand(name: string, callback: EditorCommandCallback, scope: S): void; + addCommand(name: string, callback: EditorCommandCallback): void; + addQueryStateHandler(name: string, callback: (this: S) => boolean, scope?: S): void; + addQueryStateHandler(name: string, callback: (this: Editor) => boolean): void; + addQueryValueHandler(name: string, callback: (this: S) => string, scope: S): void; + addQueryValueHandler(name: string, callback: (this: Editor) => string): void; + addShortcut(pattern: string, desc: string, cmdFunc: string | [ + string, + boolean, + any + ] | (() => void), scope?: any): void; + execCommand(cmd: string, ui?: boolean, value?: any, args?: ExecCommandArgs): boolean; + queryCommandState(cmd: string): boolean; + queryCommandValue(cmd: string): string; + queryCommandSupported(cmd: string): boolean; + show(): void; + hide(): void; + isHidden(): boolean; + setProgressState(state: boolean, time?: number): void; + load(args?: Partial): string; + save(args?: Partial): string; + setContent(content: string, args?: Partial): string; + setContent(content: AstNode, args?: Partial): AstNode; + setContent(content: Content, args?: Partial): Content; + getContent(args: { + format: 'tree'; + } & Partial): AstNode; + getContent(args?: Partial): string; + insertContent(content: string, args?: any): void; + resetContent(initialContent?: string): void; + isDirty(): boolean; + setDirty(state: boolean): void; + getContainer(): HTMLElement; + getContentAreaContainer(): HTMLElement; + getElement(): HTMLElement; + getWin(): Window; + getDoc(): Document; + getBody(): HTMLElement; + convertURL(url: string, name: string, elm?: string | Element): string; + addVisual(elm?: HTMLElement): void; + setEditableRoot(state: boolean): void; + hasEditableRoot(): boolean; + remove(): void; + destroy(automatic?: boolean): void; + uploadImages(): Promise; + _scanForImages(): Promise; +} +interface UrlObject { + prefix: string; + resource: string; + suffix: string; +} +type WaitState = 'added' | 'loaded'; +type AddOnConstructor = (editor: Editor, url: string) => T; +interface AddOnManager { + items: AddOnConstructor[]; + urls: Record; + lookup: Record; + }>; + get: (name: string) => AddOnConstructor | undefined; + requireLangPack: (name: string, languages?: string) => void; + add: (id: string, addOn: AddOnConstructor) => AddOnConstructor; + remove: (name: string) => void; + createUrl: (baseUrl: UrlObject, dep: string | UrlObject) => UrlObject; + load: (name: string, addOnUrl: string | UrlObject) => Promise; + waitFor: (name: string, state?: WaitState) => Promise; +} +interface RangeUtils { + walk: (rng: Range, callback: (nodes: Node[]) => void) => void; + split: (rng: Range) => RangeLikeObject; + normalize: (rng: Range) => boolean; + expand: (rng: Range, options?: { + type: 'word'; + }) => Range; +} +interface ScriptLoaderSettings { + referrerPolicy?: ReferrerPolicy; +} +interface ScriptLoaderConstructor { + readonly prototype: ScriptLoader; + new (): ScriptLoader; + ScriptLoader: ScriptLoader; +} +declare class ScriptLoader { + static ScriptLoader: ScriptLoader; + private settings; + private states; + private queue; + private scriptLoadedCallbacks; + private queueLoadedCallbacks; + private loading; + constructor(settings?: ScriptLoaderSettings); + _setReferrerPolicy(referrerPolicy: ReferrerPolicy): void; + loadScript(url: string): Promise; + isDone(url: string): boolean; + markDone(url: string): void; + add(url: string): Promise; + load(url: string): Promise; + remove(url: string): void; + loadQueue(): Promise; + loadScripts(scripts: string[]): Promise; +} +type TextProcessCallback = (node: Text, offset: number, text: string) => number; +interface Spot { + container: Text; + offset: number; +} +interface TextSeeker { + backwards: (node: Node, offset: number, process: TextProcessCallback, root?: Node) => Spot | null; + forwards: (node: Node, offset: number, process: TextProcessCallback, root?: Node) => Spot | null; +} +interface DomTreeWalkerConstructor { + readonly prototype: DomTreeWalker; + new (startNode: Node, rootNode: Node): DomTreeWalker; +} +declare class DomTreeWalker { + private readonly rootNode; + private node; + constructor(startNode: Node, rootNode: Node); + current(): Node | null | undefined; + next(shallow?: boolean): Node | null | undefined; + prev(shallow?: boolean): Node | null | undefined; + prev2(shallow?: boolean): Node | null | undefined; + private findSibling; + private findPreviousNode; +} +interface Version { + major: number; + minor: number; +} +interface Env { + transparentSrc: string; + documentMode: number; + cacheSuffix: any; + container: any; + canHaveCSP: boolean; + windowsPhone: boolean; + browser: { + current: string | undefined; + version: Version; + isEdge: () => boolean; + isChromium: () => boolean; + isIE: () => boolean; + isOpera: () => boolean; + isFirefox: () => boolean; + isSafari: () => boolean; + }; + os: { + current: string | undefined; + version: Version; + isWindows: () => boolean; + isiOS: () => boolean; + isAndroid: () => boolean; + isMacOS: () => boolean; + isLinux: () => boolean; + isSolaris: () => boolean; + isFreeBSD: () => boolean; + isChromeOS: () => boolean; + }; + deviceType: { + isiPad: () => boolean; + isiPhone: () => boolean; + isTablet: () => boolean; + isPhone: () => boolean; + isTouch: () => boolean; + isWebView: () => boolean; + isDesktop: () => boolean; + }; +} +interface FakeClipboardItem { + readonly items: Record; + readonly types: ReadonlyArray; + readonly getType: (type: string) => D | undefined; +} +interface FakeClipboard { + readonly FakeClipboardItem: (items: Record) => FakeClipboardItem; + readonly write: (data: FakeClipboardItem[]) => void; + readonly read: () => FakeClipboardItem[] | undefined; + readonly clear: () => void; +} +interface FocusManager { + isEditorUIElement: (elm: Element) => boolean; +} +interface EntitiesMap { + [name: string]: string; +} +interface Entities { + encodeRaw: (text: string, attr?: boolean) => string; + encodeAllRaw: (text: string) => string; + encodeNumeric: (text: string, attr?: boolean) => string; + encodeNamed: (text: string, attr?: boolean, entities?: EntitiesMap) => string; + getEncodeFunc: (name: string, entities?: string) => (text: string, attr?: boolean) => string; + decode: (text: string) => string; +} +interface IconPack { + icons: Record; +} +interface IconManager { + add: (id: string, iconPack: IconPack) => void; + get: (id: string) => IconPack; + has: (id: string) => boolean; +} +interface Resource { + load: (id: string, url: string) => Promise; + add: (id: string, data: any) => void; + has: (id: string) => boolean; + get: (id: string) => any; + unload: (id: string) => void; +} +type TextPatterns_d_Pattern = Pattern; +type TextPatterns_d_RawPattern = RawPattern; +type TextPatterns_d_DynamicPatternsLookup = DynamicPatternsLookup; +type TextPatterns_d_RawDynamicPatternsLookup = RawDynamicPatternsLookup; +type TextPatterns_d_DynamicPatternContext = DynamicPatternContext; +type TextPatterns_d_BlockCmdPattern = BlockCmdPattern; +type TextPatterns_d_BlockPattern = BlockPattern; +type TextPatterns_d_BlockFormatPattern = BlockFormatPattern; +type TextPatterns_d_InlineCmdPattern = InlineCmdPattern; +type TextPatterns_d_InlinePattern = InlinePattern; +type TextPatterns_d_InlineFormatPattern = InlineFormatPattern; +declare namespace TextPatterns_d { + export { TextPatterns_d_Pattern as Pattern, TextPatterns_d_RawPattern as RawPattern, TextPatterns_d_DynamicPatternsLookup as DynamicPatternsLookup, TextPatterns_d_RawDynamicPatternsLookup as RawDynamicPatternsLookup, TextPatterns_d_DynamicPatternContext as DynamicPatternContext, TextPatterns_d_BlockCmdPattern as BlockCmdPattern, TextPatterns_d_BlockPattern as BlockPattern, TextPatterns_d_BlockFormatPattern as BlockFormatPattern, TextPatterns_d_InlineCmdPattern as InlineCmdPattern, TextPatterns_d_InlinePattern as InlinePattern, TextPatterns_d_InlineFormatPattern as InlineFormatPattern, }; +} +interface Delay { + setEditorInterval: (editor: Editor, callback: () => void, time?: number) => number; + setEditorTimeout: (editor: Editor, callback: () => void, time?: number) => number; +} +type UploadResult = UploadResult$2; +interface ImageUploader { + upload: (blobInfos: BlobInfo[], showNotification?: boolean) => Promise; +} +type ArrayCallback$1 = (this: any, x: T, i: number, xs: ArrayLike) => R; +type ObjCallback$1 = (this: any, value: T, key: string, obj: Record) => R; +type ArrayCallback = ArrayCallback$1; +type ObjCallback = ObjCallback$1; +type WalkCallback = (this: any, o: T, i: string, n: keyof T | undefined) => boolean | void; +interface Tools { + is: (obj: any, type?: string) => boolean; + isArray: (arr: any) => arr is Array; + inArray: (arr: ArrayLike, value: T) => number; + grep: { + (arr: ArrayLike | null | undefined, pred?: ArrayCallback): T[]; + (arr: Record | null | undefined, pred?: ObjCallback): T[]; + }; + trim: (str: string | null | undefined) => string; + toArray: (obj: ArrayLike) => T[]; + hasOwn: (obj: any, name: string) => boolean; + makeMap: (items: ArrayLike | string | undefined, delim?: string | RegExp, map?: Record) => Record; + each: { + (arr: ArrayLike | null | undefined, cb: ArrayCallback, scope?: any): boolean; + (obj: Record | null | undefined, cb: ObjCallback, scope?: any): boolean; + }; + map: { + (arr: ArrayLike | null | undefined, cb: ArrayCallback): R[]; + (obj: Record | null | undefined, cb: ObjCallback): R[]; + }; + extend: (obj: Object, ext: Object, ...objs: Object[]) => any; + walk: >(obj: T, f: WalkCallback, n?: keyof T, scope?: any) => void; + resolve: (path: string, o?: Object) => any; + explode: (s: string | string[], d?: string | RegExp) => string[]; + _addCacheSuffix: (url: string) => string; +} +interface KeyboardLikeEvent { + shiftKey: boolean; + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; +} +interface VK { + BACKSPACE: number; + DELETE: number; + DOWN: number; + ENTER: number; + ESC: number; + LEFT: number; + RIGHT: number; + SPACEBAR: number; + TAB: number; + UP: number; + PAGE_UP: number; + PAGE_DOWN: number; + END: number; + HOME: number; + modifierPressed: (e: KeyboardLikeEvent) => boolean; + metaKeyPressed: (e: KeyboardLikeEvent) => boolean; +} +interface DOMUtilsNamespace { + (doc: Document, settings: Partial): DOMUtils; + DOM: DOMUtils; + nodeIndex: (node: Node, normalized?: boolean) => number; +} +interface RangeUtilsNamespace { + (dom: DOMUtils): RangeUtils; + compareRanges: (rng1: RangeLikeObject, rng2: RangeLikeObject) => boolean; + getCaretRangeFromPoint: (clientX: number, clientY: number, doc: Document) => Range; + getSelectedNode: (range: Range) => Node; + getNode: (container: Node, offset: number) => Node; +} +interface AddOnManagerNamespace { + (): AddOnManager; + language: string | undefined; + languageLoad: boolean; + baseURL: string; + PluginManager: PluginManager; + ThemeManager: ThemeManager; + ModelManager: ModelManager; +} +interface BookmarkManagerNamespace { + (selection: EditorSelection): BookmarkManager; + isBookmarkNode: (node: Node) => boolean; +} +interface TinyMCE extends EditorManager { + geom: { + Rect: Rect; + }; + util: { + Delay: Delay; + Tools: Tools; + VK: VK; + URI: URIConstructor; + EventDispatcher: EventDispatcherConstructor; + Observable: Observable; + I18n: I18n; + LocalStorage: Storage; + ImageUploader: ImageUploader; + }; + dom: { + EventUtils: EventUtilsConstructor; + TreeWalker: DomTreeWalkerConstructor; + TextSeeker: (dom: DOMUtils, isBlockBoundary?: (node: Node) => boolean) => TextSeeker; + DOMUtils: DOMUtilsNamespace; + ScriptLoader: ScriptLoaderConstructor; + RangeUtils: RangeUtilsNamespace; + Serializer: (settings: DomSerializerSettings, editor?: Editor) => DomSerializer; + ControlSelection: (selection: EditorSelection, editor: Editor) => ControlSelection; + BookmarkManager: BookmarkManagerNamespace; + Selection: (dom: DOMUtils, win: Window, serializer: DomSerializer, editor: Editor) => EditorSelection; + StyleSheetLoader: (documentOrShadowRoot: Document | ShadowRoot, settings: StyleSheetLoaderSettings) => StyleSheetLoader; + Event: EventUtils; + }; + html: { + Styles: (settings?: StylesSettings, schema?: Schema) => Styles; + Entities: Entities; + Node: AstNodeConstructor; + Schema: (settings?: SchemaSettings) => Schema; + DomParser: (settings?: DomParserSettings, schema?: Schema) => DomParser; + Writer: (settings?: WriterSettings) => Writer; + Serializer: (settings?: HtmlSerializerSettings, schema?: Schema) => HtmlSerializer; + }; + AddOnManager: AddOnManagerNamespace; + Annotator: (editor: Editor) => Annotator; + Editor: EditorConstructor; + EditorCommands: EditorCommandsConstructor; + EditorManager: EditorManager; + EditorObservable: EditorObservable; + Env: Env; + FocusManager: FocusManager; + Formatter: (editor: Editor) => Formatter; + NotificationManager: (editor: Editor) => NotificationManager; + Shortcuts: ShortcutsConstructor; + UndoManager: (editor: Editor) => UndoManager; + WindowManager: (editor: Editor) => WindowManager; + DOM: DOMUtils; + ScriptLoader: ScriptLoader; + PluginManager: PluginManager; + ThemeManager: ThemeManager; + ModelManager: ModelManager; + IconManager: IconManager; + Resource: Resource; + FakeClipboard: FakeClipboard; + trim: Tools['trim']; + isArray: Tools['isArray']; + is: Tools['is']; + toArray: Tools['toArray']; + makeMap: Tools['makeMap']; + each: Tools['each']; + map: Tools['map']; + grep: Tools['grep']; + inArray: Tools['inArray']; + extend: Tools['extend']; + walk: Tools['walk']; + resolve: Tools['resolve']; + explode: Tools['explode']; + _addCacheSuffix: Tools['_addCacheSuffix']; +} +declare const tinymce: TinyMCE; +export { + AddOnConstructor, + AddOnManager, + AddOnManagerNamespace, + AddUndoEvent, + AfterProgressStateEvent, + AlertBannerSpec, + Alignment, + AllowedFormat, + AnnotationListener, + AnnotationListenerApi, + Annotator, + AnnotatorSettings, + ApplyFormat, + ArrayCallback, + ArrayCallback$1, + AstNodeConstructor, + Attribute, + AttributePattern, + Attributes, + Attributes$1, + AutocompleteLookupData, + AutocompleterContents, + AutocompleterEventArgs, + AutocompleterInstanceApi, + AutocompleterItemSpec, + AutocompleterSpec, + BarSpec, + BaseButtonSpec, + BaseDialogFooterButtonSpec, + BaseEditorOptions, + BaseFancyMenuItemSpec, + BaseFormat, + BaseMenuButtonInstanceApi, + BaseMenuButtonSpec, + BaseOptionSpec, + BaseToolbarButtonInstanceApi, + BaseToolbarButtonSpec, + BaseToolbarToggleButtonInstanceApi, + BaseToolbarToggleButtonSpec, + BaseTreeItemSpec, + BaseUndoLevel, + BeforeGetContentEvent, + BeforeOpenNotificationEvent, + BeforeSetContentEvent, + BlobCache, + BlobInfo, + BlobInfoData, + BlobInfoImagePair, + Block, + BlockBasePattern, + BlockCmdPattern, + BlockFormat, + BlockFormatPattern, + BlockPattern, + BlockPatternTrigger, + BlockStyleFormat, + BodyComponentSpec, + Bookmark, + BookmarkManager, + BookmarkManagerNamespace, + BoundEvent, + BuiltInOptionSpec, + BuiltInOptionType, + BuiltInOptionTypeMap, + ButtonSpec, + Callback, + Callback$1, + CallbackList, + CardContainerAlign, + CardContainerDirection, + CardContainerSpec, + CardContainerValign, + CardImageSpec, + CardItemSpec, + CardMenuItemInstanceApi, + CardMenuItemSpec, + CardTextSpec, + ChangeEvent, + CheckboxSpec, + ChoiceMenuItemInstanceApi, + ChoiceMenuItemSpec, + ClientRect, + CollectionItem, + CollectionSpec, + ColorInputSpec, + ColorPickerSpec, + ColorSwatchMenuItemSpec, + ColumnTypes, + ColumnTypes$1, + CommandFunc, + Commands, + CommonFormat, + CommonMenuItemInstanceApi, + CommonMenuItemSpec, + CommonRemoveFormat, + CommonStyleFormat, + CompleteUndoLevel, + Content, + ContentFormat, + ContentLanguage, + ContextBarSpec, + ContextFormButtonInstanceApi, + ContextFormButtonSpec, + ContextFormInstanceApi, + ContextFormLaunchButtonApi, + ContextFormLaunchToggleButtonSpec, + ContextFormSpec, + ContextFormToggleButtonInstanceApi, + ContextFormToggleButtonSpec, + ContextMenuApi, + ContextMenuContents, + ContextMenuItem, + ContextPosition, + ContextScope, + ContextSubMenu, + ContextToolbarSpec, + ControlSelection, + CustomEditorInit, + CustomEditorInitFn, + CustomEditorNewSpec, + CustomEditorOldSpec, + CustomEditorSpec, + CustomElementSpec, + Decorator, + DecoratorData, + DefaultAttribute, + Delay, + DialogActionDetails, + DialogActionHandler, + DialogCancelHandler, + DialogChangeDetails, + DialogChangeHandler, + DialogCloseHandler, + DialogData, + DialogDataItem, + DialogFooterButtonSpec, + DialogFooterMenuButtonItemSpec, + DialogFooterMenuButtonSpec, + DialogFooterNormalButtonSpec, + DialogFooterToggleButtonSpec, + DialogInstanceApi, + DialogSize, + DialogSpec, + DialogSubmitHandler, + DialogTabChangeDetails, + DialogTabChangeHandler, + DialogToggleMenuItemSpec, + DirectorySpec, + DomParser, + DomParserSettings, + DomSerializer, + DomSerializerImpl, + DomSerializerSettings, + DomTreeWalkerConstructor, + DOMUtils, + DOMUtilsNamespace, + DOMUtilsSettings, + DropZoneSpec, + DynamicPatternContext, + DynamicPatternsLookup, + EditableRootStateChangeEvent, + EditorCommandCallback, + EditorCommandsCallback, + EditorCommandsConstructor, + EditorConstructor, + EditorEvent, + EditorEventMap, + EditorManager, + EditorManagerEventMap, + EditorMode, + EditorModeApi, + EditorObservable, + EditorOptions, + EditorSelection, + EditorUi, + EditorUiApi, + EditorUpload, + ElementRule, + ElementSettings, + Entities, + EntitiesMap, + EntityEncoding, + Env, + EventDispatcherConstructor, + EventDispatcherSettings, + EventTypes_d_AddUndoEvent, + EventTypes_d_AfterProgressStateEvent, + EventTypes_d_BeforeGetContentEvent, + EventTypes_d_BeforeOpenNotificationEvent, + EventTypes_d_BeforeSetContentEvent, + EventTypes_d_ChangeEvent, + EventTypes_d_EditableRootStateChangeEvent, + EventTypes_d_EditorEventMap, + EventTypes_d_EditorManagerEventMap, + EventTypes_d_ExecCommandEvent, + EventTypes_d_FormatEvent, + EventTypes_d_GetContentEvent, + EventTypes_d_LoadErrorEvent, + EventTypes_d_NewBlockEvent, + EventTypes_d_NewTableCellEvent, + EventTypes_d_NewTableRowEvent, + EventTypes_d_NodeChangeEvent, + EventTypes_d_ObjectResizeEvent, + EventTypes_d_ObjectSelectedEvent, + EventTypes_d_OpenNotificationEvent, + EventTypes_d_PastePlainTextToggleEvent, + EventTypes_d_PastePostProcessEvent, + EventTypes_d_PastePreProcessEvent, + EventTypes_d_PlaceholderToggleEvent, + EventTypes_d_PostProcessEvent, + EventTypes_d_PreProcessEvent, + EventTypes_d_ProgressStateEvent, + EventTypes_d_SaveContentEvent, + EventTypes_d_ScrollIntoViewEvent, + EventTypes_d_SetContentEvent, + EventTypes_d_SetSelectionRangeEvent, + EventTypes_d_ShowCaretEvent, + EventTypes_d_SwitchModeEvent, + EventTypes_d_TableEventData, + EventTypes_d_TableModifiedEvent, + EventTypes_d_UndoRedoEvent, + EventTypes_d_WindowEvent, + EventUtilsCallback, + EventUtilsConstructor, + EventUtilsEvent, + ExecCommandArgs, + ExecCommandEvent, + FakeClipboard, + FakeClipboardItem, + FancyActionArgsMap, + FancyMenuItemSpec, + FilePickerCallback, + FilePickerValidationCallback, + FilePickerValidationStatus, + Filter, + FocusManager, + Format, + Format_d_ApplyFormat, + Format_d_BlockFormat, + Format_d_Format, + Format_d_Formats, + Format_d_InlineFormat, + Format_d_RemoveBlockFormat, + Format_d_RemoveFormat, + Format_d_RemoveInlineFormat, + Format_d_RemoveSelectorFormat, + Format_d_SelectorFormat, + FormatAttrOrStyleValue, + FormatChangeCallback, + FormatEvent, + FormatReference, + FormatRegistry, + Formats, + Formatter, + FormatVars, + FormComponentSpec, + FormComponentWithLabelSpec, + FragmentedUndoLevel, + GeomRect, + GetContentArgs, + GetContentEvent, + GetSelectionContentArgs, + GridSpec, + GroupToolbarButtonInstanceApi, + GroupToolbarButtonSpec, + HtmlPanelSpec, + HtmlSerializer, + HtmlSerializerSettings, + I18n, + IconManager, + IconPack, + Id, + IdBookmark, + IframeSpec, + ImagePreviewSpec, + ImageUploader, + IndexBookmark, + Inline, + InlineBasePattern, + InlineCmdPattern, + InlineFormat, + InlineFormatPattern, + InlinePattern, + InlineStyleFormat, + InputSpec, + InsertTableMenuItemSpec, + InstanceApi, + IsEmptyOptions, + KeyboardLikeEvent, + LabelSpec, + LeafSpec, + ListBoxItemSpec, + ListBoxNestedItemSpec, + ListBoxSingleItemSpec, + ListBoxSpec, + LoadErrorEvent, + MappedEvent, + MenuButtonFetchContext, + MenuButtonItemTypes, + MenuItemInstanceApi, + MenuItemSpec, + Model, + ModelManager, + NativeEventMap, + NestedFormatting, + NestedMenuItemContents, + NestedMenuItemInstanceApi, + NestedMenuItemSpec, + NewBlockEvent, + NewTableCellEvent, + NewTableRowEvent, + NewUndoLevel, + NodeChangeEvent, + NormalizedEditorOptions, + NormalizedEvent, + NotificationApi, + NotificationManager, + NotificationManagerImpl, + NotificationSpec, + ObjCallback, + ObjCallback$1, + ObjectResizeEvent, + ObjectSelectedEvent, + Observable, + OpenNotificationEvent, + Options, + OptionSpec, + PanelSpec, + ParserArgs, + ParserFilter, + ParserFilterCallback, + PastePlainTextToggleEvent, + PastePostProcessEvent, + PastePostProcessFn, + PastePreProcessEvent, + PastePreProcessFn, + PathBookmark, + Pattern, + PlaceholderToggleEvent, + Plugin, + PluginManager, + PostProcessEvent, + PreProcessEvent, + PresetTypes, + Primitive, + Processor, + ProcessorError, + ProcessorSuccess, + ProgressFn, + ProgressStateEvent, + PublicDialog_d_AlertBannerSpec, + PublicDialog_d_BarSpec, + PublicDialog_d_BodyComponentSpec, + PublicDialog_d_ButtonSpec, + PublicDialog_d_CheckboxSpec, + PublicDialog_d_CollectionItem, + PublicDialog_d_CollectionSpec, + PublicDialog_d_ColorInputSpec, + PublicDialog_d_ColorPickerSpec, + PublicDialog_d_CustomEditorInit, + PublicDialog_d_CustomEditorInitFn, + PublicDialog_d_CustomEditorSpec, + PublicDialog_d_DialogActionDetails, + PublicDialog_d_DialogChangeDetails, + PublicDialog_d_DialogData, + PublicDialog_d_DialogFooterButtonSpec, + PublicDialog_d_DialogInstanceApi, + PublicDialog_d_DialogSize, + PublicDialog_d_DialogSpec, + PublicDialog_d_DialogTabChangeDetails, + PublicDialog_d_DropZoneSpec, + PublicDialog_d_GridSpec, + PublicDialog_d_HtmlPanelSpec, + PublicDialog_d_IframeSpec, + PublicDialog_d_ImagePreviewSpec, + PublicDialog_d_InputSpec, + PublicDialog_d_LabelSpec, + PublicDialog_d_ListBoxItemSpec, + PublicDialog_d_ListBoxNestedItemSpec, + PublicDialog_d_ListBoxSingleItemSpec, + PublicDialog_d_ListBoxSpec, + PublicDialog_d_PanelSpec, + PublicDialog_d_SelectBoxItemSpec, + PublicDialog_d_SelectBoxSpec, + PublicDialog_d_SizeInputSpec, + PublicDialog_d_SliderSpec, + PublicDialog_d_TableSpec, + PublicDialog_d_TabPanelSpec, + PublicDialog_d_TabSpec, + PublicDialog_d_TextAreaSpec, + PublicDialog_d_TreeItemSpec, + PublicDialog_d_TreeSpec, + PublicDialog_d_UrlDialogActionDetails, + PublicDialog_d_UrlDialogFooterButtonSpec, + PublicDialog_d_UrlDialogInstanceApi, + PublicDialog_d_UrlDialogMessage, + PublicDialog_d_UrlDialogSpec, + PublicDialog_d_UrlInputData, + PublicDialog_d_UrlInputSpec, + PublicInlineContent_d_AutocompleterContents, + PublicInlineContent_d_AutocompleterInstanceApi, + PublicInlineContent_d_AutocompleterItemSpec, + PublicInlineContent_d_AutocompleterSpec, + PublicInlineContent_d_ContextFormButtonInstanceApi, + PublicInlineContent_d_ContextFormButtonSpec, + PublicInlineContent_d_ContextFormInstanceApi, + PublicInlineContent_d_ContextFormSpec, + PublicInlineContent_d_ContextFormToggleButtonInstanceApi, + PublicInlineContent_d_ContextFormToggleButtonSpec, + PublicInlineContent_d_ContextPosition, + PublicInlineContent_d_ContextScope, + PublicInlineContent_d_ContextToolbarSpec, + PublicInlineContent_d_SeparatorItemSpec, + PublicMenu_d_CardContainerSpec, + PublicMenu_d_CardImageSpec, + PublicMenu_d_CardItemSpec, + PublicMenu_d_CardMenuItemInstanceApi, + PublicMenu_d_CardMenuItemSpec, + PublicMenu_d_CardTextSpec, + PublicMenu_d_ChoiceMenuItemInstanceApi, + PublicMenu_d_ChoiceMenuItemSpec, + PublicMenu_d_ColorSwatchMenuItemSpec, + PublicMenu_d_ContextMenuApi, + PublicMenu_d_ContextMenuContents, + PublicMenu_d_ContextMenuItem, + PublicMenu_d_ContextSubMenu, + PublicMenu_d_FancyMenuItemSpec, + PublicMenu_d_InsertTableMenuItemSpec, + PublicMenu_d_MenuItemInstanceApi, + PublicMenu_d_MenuItemSpec, + PublicMenu_d_NestedMenuItemContents, + PublicMenu_d_NestedMenuItemInstanceApi, + PublicMenu_d_NestedMenuItemSpec, + PublicMenu_d_SeparatorMenuItemSpec, + PublicMenu_d_ToggleMenuItemInstanceApi, + PublicMenu_d_ToggleMenuItemSpec, + PublicSidebar_d_SidebarInstanceApi, + PublicSidebar_d_SidebarSpec, + PublicToolbar_d_GroupToolbarButtonInstanceApi, + PublicToolbar_d_GroupToolbarButtonSpec, + PublicToolbar_d_ToolbarButtonInstanceApi, + PublicToolbar_d_ToolbarButtonSpec, + PublicToolbar_d_ToolbarMenuButtonInstanceApi, + PublicToolbar_d_ToolbarMenuButtonSpec, + PublicToolbar_d_ToolbarSplitButtonInstanceApi, + PublicToolbar_d_ToolbarSplitButtonSpec, + PublicToolbar_d_ToolbarToggleButtonInstanceApi, + PublicToolbar_d_ToolbarToggleButtonSpec, + PublicView_d_ViewInstanceApi, + PublicView_d_ViewSpec, + Quirks, + RangeBookmark, + RangeLikeObject, + RangeUtils, + RangeUtilsNamespace, + RawDynamicPatternsLookup, + RawEditorOptions, + RawPattern, + RawString, + Rect, + Registry, + Registry$1, + RemoveBlockFormat, + RemoveFormat, + RemoveInlineFormat, + RemoveSelectorFormat, + RenderResult, + Resource, + RunArguments, + RunResult, + SafeUriOptions, + SaveContentEvent, + Schema, + SchemaElement, + SchemaMap, + SchemaRegExpMap, + SchemaSettings, + SchemaType, + ScriptLoaderConstructor, + ScriptLoaderSettings, + ScrollIntoViewEvent, + SelectBoxItemSpec, + SelectBoxSpec, + SelectionOverrides, + Selector, + SelectorFormat, + SelectorStyleFormat, + SelectPredicate, + Separator, + SeparatorItemSpec, + SeparatorMenuItemSpec, + SetAttribEvent, + SetContentArgs, + SetContentEvent, + SetSelectionContentArgs, + SetSelectionRangeEvent, + SetupCallback, + ShortcutsConstructor, + ShowCaretEvent, + SidebarInstanceApi, + SidebarSpec, + SimpleOptionSpec, + SimpleProcessor, + SizeInputSpec, + SliderSpec, + Spot, + StringPathBookmark, + StyleFormat, + StyleMap, + Styles, + StyleSheetLoader, + StyleSheetLoaderSettings, + StylesSettings, + SuccessCallback, + SuccessCallback$1, + SwitchModeEvent, + TableEventData, + TableModifiedEvent, + TableSpec, + TabPanelSpec, + TabSpec, + Target, + TextAreaSpec, + TextPatterns_d_BlockCmdPattern, + TextPatterns_d_BlockFormatPattern, + TextPatterns_d_BlockPattern, + TextPatterns_d_DynamicPatternContext, + TextPatterns_d_DynamicPatternsLookup, + TextPatterns_d_InlineCmdPattern, + TextPatterns_d_InlineFormatPattern, + TextPatterns_d_InlinePattern, + TextPatterns_d_Pattern, + TextPatterns_d_RawDynamicPatternsLookup, + TextPatterns_d_RawPattern, + TextProcessCallback, + TextSeeker, + Theme, + ThemeInitFunc, + ThemeManager, + TinyMCE, + ToggleMenuItemInstanceApi, + ToggleMenuItemSpec, + TokenisedString, + ToolbarButtonInstanceApi, + ToolbarButtonSpec, + ToolbarConfig, + ToolbarGroup, + ToolbarGroupSetting, + ToolbarLocation, + ToolbarMenuButtonInstanceApi, + ToolbarMenuButtonSpec, + ToolbarMode, + ToolbarSplitButtonInstanceApi, + ToolbarSplitButtonItemTypes, + ToolbarSplitButtonSpec, + ToolbarToggleButtonInstanceApi, + ToolbarToggleButtonSpec, + Tools, + TranslatedString, + TreeItemSpec, + TreeSpec, + Ui_d_EditorUi, + Ui_d_EditorUiApi, + Ui_d_Registry, + UndoLevel, + UndoLevelType, + UndoManager, + UndoRedoEvent, + Untranslated, + UploadFailure, + UploadHandler, + UploadResult, + UploadResult$1, + UploadResult$2, + URIConstructor, + URISettings, + URLConverter, + URLConverterCallback, + UrlDialogActionDetails, + UrlDialogActionHandler, + UrlDialogCancelHandler, + UrlDialogCloseHandler, + UrlDialogFooterButtonSpec, + UrlDialogInstanceApi, + UrlDialogMessage, + UrlDialogMessageHandler, + UrlDialogSpec, + UrlInputData, + UrlInputSpec, + UrlObject, + Version, + ViewButtonApi, + ViewButtonsGroupSpec, + ViewButtonSpec, + ViewInstanceApi, + ViewNormalButtonSpec, + ViewSpec, + ViewToggleButtonApi, + ViewToggleButtonSpec, + VK, + WaitState, + WalkCallback, + WindowEvent, + WindowManager, + WindowManagerImpl, + WindowParams, + Writer, + WriterSettings, +}; From 1831ed4743a34cf11880b27df723048d980eaa2e Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Fri, 3 Oct 2025 19:25:07 +0200 Subject: [PATCH 03/26] WIP add comments describing methods --- .../static/src/js/tinymce-plugins/shortcodes/utils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index b14e9678e6..9954bd48df 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -58,6 +58,7 @@ class ShortcodeHandle { } text(text: TextDescriptor): string { + // A helper method to render a string from either a fixed value or a dynamic function if (typeof text === "string") { return text; } @@ -65,16 +66,20 @@ class ShortcodeHandle { } predicate(node: Element): boolean { + // A method determining whether a node in TinyMCE represents this shortcode + // (e.g. whether the toolbar specific to this shortcode should be shown) if (!(node instanceof HTMLElement)) return false; return "shortcode" in node.dataset && node.dataset.shortcode == this.keyword; } getNode(): HTMLElement | null { + // A helper method to get the shortcode node the user has currently selected const node = this.editor.selection.getNode(); return this.predicate(node) ? node : null; }; sortKWargs(kwpairs: [string, string][]): [string, string][] { + // A helper method determining a canonical order for keyword arguments const order = this.kwargs !== null ? this.kwargs.map(kw => typeof kw === "string" ? kw : kw[0]) : []; return kwpairs.sort((a, b) => { const aPos = order.includes(a[0]) ? order.indexOf(a[0]) : order.length; @@ -84,12 +89,14 @@ class ShortcodeHandle { } renderShortcode(pargs: string[], kwargs: Map): string { + // The canonical text representation of the shortcode const pairs = this.sortKWargs(Object.entries(kwargs)).map(pair => pair.map(escape).join("=")); const parts = [escape(this.keyword), ...pargs.map(escape), ...pairs]; return `[${parts.join(" ")}]`; } renderPreview(pargs: string[], kwargs: Map): string { + // The html string representation of the shortcode in the TinyMCE editor const ppairs = pargs.map((arg, i) => `data-parg${i}="${arg}"`); const kwpairs = this.sortKWargs(Object.entries(kwargs)).map(([key, value]) => `data-kw-${key}=${escape(value)}`); const parts = [`class="mceNonEditable"`, `data-shortcode="${this.keyword}"`, ...ppairs, ...kwpairs]; @@ -97,6 +104,7 @@ class ShortcodeHandle { } openEditDialog(formApi: MenuItemInstanceApi | ContextFormInstanceApi) { + // The default implementation for constructing the edit dialog for a generic shortcode const node = this.getNode(); const initialPargs = node !== null ? node.dataset.pargs.split(" ") : []; From 761bd6a3e288546c720aa8f18d839f0216f21a5f Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Sun, 5 Oct 2025 19:21:39 +0200 Subject: [PATCH 04/26] WIP fix parsing, handle unknown shortcode --- .../js/tinymce-plugins/shortcodes/plugin.js | 23 +++- .../tinymce-plugins/shortcodes/shortcodes.ts | 26 +++- .../js/tinymce-plugins/shortcodes/utils.ts | 124 +++++++++++++----- 3 files changed, 128 insertions(+), 45 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js index ca52733adc..8eefc32169 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js @@ -1,15 +1,22 @@ import { Parser } from "./shortcodes"; import ContactHandle from "./contact"; import PageHandle from "./page"; -import { Registry } from "./utils"; +import { Registry, ShortcodeHandle } from "./utils"; Registry.register(new PageHandle()); -Registry.register(new ContactHandle()); +//Registry.register(new ContactHandle()); + +function DummyHandleFactory(keyword) { + const handle = new ShortcodeHandle(); + handle.keyword = keyword; + return handle; +} +Registry.setUnknownHandleFactory(DummyHandleFactory); (() => { const tinymceConfig = document.getElementById("tinymce-config-options"); - const parser = new Parser("[", "]", "\\", true); + const parser = new Parser("[", "]", "\\", true, true); const context = { language: tinymceConfig.getAttribute("data-language"), directionality: tinymceConfig.getAttribute("data-directionality"), @@ -38,9 +45,15 @@ Registry.register(new ContactHandle()); } }); - editor.on('PostProcess', function(e) { + editor.on('PreProcess', function(e) { // Strip the mce marker out when extracting the content for saving or the source code view - e.content = e.content.replace(/]*)>([^<]+)<\/span>/g, '$2'); + console.log(`PreProcess – restoring canonical form`, e); + const shortcodes = Array.from(e.node.querySelectorAll('span.mceNonEditable[data-shortcode]')); + shortcodes.forEach(node => { + const keyword = node.dataset.shortcode; + const handle = Registry.get(keyword); + node.outerText = handle.renderShortcode(...handle.argsFromNode(node)); + }); }); /* diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts index e6b2afefe6..f5287472f8 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts @@ -96,7 +96,7 @@ class Text extends ASTNode { // Base class for atomic and block-scoped shortcodes. class Shortcode extends ASTNode { // Regex for parsing the shortcode's arguments. - re_args = new RegExp(` + re_args = new RegExp(String.raw` (?:([^\s'"=]+)=)? ( "((?:[^\\"]|\\.)*)" @@ -107,7 +107,7 @@ class Shortcode extends ASTNode { ([^\s'"=]+)=(\S+) | (\S+) - `, "g"); + `.replace(/\s+/g, ""), "g"); handler: (pargs: string[], kwargs: Map, context: any, content?: string) => string; pargs: string[]; @@ -126,18 +126,19 @@ class Shortcode extends ASTNode { const pargs: string[] = []; const kwargs = new Map(); for (const match of argstring.matchAll(this.re_args)) { - if (match.groups[2] || match.groups[5]) { - const key = match.groups[1] || match.groups[5]; - const value = match.groups[3] || match.groups[4] || match.groups[6]; + if (match[2] || match[5]) { + const key = match[1] || match[5]; + const value = match[3] || match[4] || match[6]; if (key) { kwargs.set(key, value); } else { pargs.push(value); } } else { - pargs.push(match.groups[7]); + pargs.push(match[7]); } } + console.log(`parsed from argstring: »${argstring}«`, pargs, kwargs); return [pargs, kwargs]; } } @@ -208,6 +209,7 @@ class Parser { keywords: Map, context: any, content?: string) => string, string]>; endwords: Set; ignore_unknown: boolean; + unknownHandlerFactory: (keyword: string) => (pargs: string[], kwargs: Map, context: any, content?: string) => string; // patched in constructor(start: string = '[%', end: string = '%]', esc: string = '\\', inherit_globals: boolean = true, ignore_unknown: boolean = false) { this.start = start; @@ -216,6 +218,7 @@ class Parser { this.keywords = new Map, context: any, content?: string) => string, string]>(inherit_globals ? global_keywords : null); this.endwords = new Set(inherit_globals ? global_endwords : null); this.ignore_unknown = ignore_unknown; + this.unknownHandlerFactory = null; // patched in } register(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string, keyword: string, endword: string = null) { @@ -225,6 +228,11 @@ class Parser { } } + // patched in + setUnknownHandlerFactory(func: (keyword: string) => (pargs: string[], kwargs: Map, context: any, content?: string) => string) { + this.unknownHandlerFactory = func; + } + parse(text: string, context: any = null) { if (!text.includes(this.start)) { return text; @@ -263,6 +271,12 @@ class Parser { const msg = `Empty shortcode tag in line ${token.line_number}.`; throw new ShortcodeSyntaxError(msg); } else if (this.ignore_unknown) { + if (this.unknownHandlerFactory !== null) { // START patched in + // Instead of treating the unknown shortcode as text, parse it as a dummy one + const node = new AtomicShortcode(token, this.unknownHandlerFactory(token.keyword)); + stack[stack.length-1].children.push(node); + continue; + } // END patched in stack[stack.length-1].children.push(new Text(token.raw_text)); } else { const msg = `Unrecognised shortcode tag '${token.keyword}' in line ${token.line_number}.` diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 9954bd48df..1e79262a0b 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -44,8 +44,8 @@ class ShortcodeHandle { removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; removeIcon: string = "unlink"; - static escape() { - return (str: string): string => str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; + static escape(str: string): string { + return str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; } pargs: PargsConstraint = null; @@ -68,8 +68,11 @@ class ShortcodeHandle { predicate(node: Element): boolean { // A method determining whether a node in TinyMCE represents this shortcode // (e.g. whether the toolbar specific to this shortcode should be shown) - if (!(node instanceof HTMLElement)) return false; - return "shortcode" in node.dataset && node.dataset.shortcode == this.keyword; + console.log(`predicate()`, node); + if (!("dataset" in node)) return false; + const dataset = (node as HTMLElement).dataset; + console.log(`dataset`, dataset, !!(dataset.shortcode), this.keyword, (dataset.shortcode == this.keyword), !this.keyword); + return dataset.shortcode && (dataset.shortcode == this.keyword || !this.keyword); } getNode(): HTMLElement | null { @@ -78,51 +81,74 @@ class ShortcodeHandle { return this.predicate(node) ? node : null; }; - sortKWargs(kwpairs: [string, string][]): [string, string][] { + sortKWargs(kwpairs: Iterable<[string, string]> | [string, string][]): [string, string][] { // A helper method determining a canonical order for keyword arguments const order = this.kwargs !== null ? this.kwargs.map(kw => typeof kw === "string" ? kw : kw[0]) : []; - return kwpairs.sort((a, b) => { + if (!(kwpairs instanceof Array)) kwpairs = Array.from(kwpairs); + return (kwpairs as Array<[string, string]>).sort((a, b) => { const aPos = order.includes(a[0]) ? order.indexOf(a[0]) : order.length; const bPos = order.includes(b[0]) ? order.indexOf(b[0]) : order.length; return aPos - bPos; }); } + argsFromNode(node: HTMLElement | null): [string[], Map] { + let prefix = "parg"; + const pargs = [...Object.entries(node !== null ? node.dataset : {})].reduce((acc, pair) => { + if (pair[0].startsWith(prefix)) { + const index = parseInt(pair[0].slice(prefix.length)); + acc[index] = pair[1]; + } + return acc; + }, []); + while (pargs.length < this.minPargs) { + pargs.push(""); + } + + prefix = "kw"; + const kwargs = [...Object.entries(node !== null ? node.dataset : {})].reduce((acc, pair) => { + if (pair[0].startsWith(prefix)) { + // Revert camelCase transformation automatically done by the dataset api + let keyword = pair[0].replace(/[A-Z]/g, c => `-${c.toLowerCase()}`); + // Strip the prefix + dash + keyword = keyword.slice(prefix.length + 1); + acc.push([keyword, pair[1]]); + } + return acc; + }, []); + + return [pargs, new Map(kwargs)]; + } + renderShortcode(pargs: string[], kwargs: Map): string { // The canonical text representation of the shortcode - const pairs = this.sortKWargs(Object.entries(kwargs)).map(pair => pair.map(escape).join("=")); - const parts = [escape(this.keyword), ...pargs.map(escape), ...pairs]; + const pairs = this.sortKWargs(kwargs.entries()).map(pair => pair.map(ShortcodeHandle.escape).join("=")); + const parts = [ShortcodeHandle.escape(this.keyword), ...pargs.map(ShortcodeHandle.escape), ...pairs]; return `[${parts.join(" ")}]`; } - renderPreview(pargs: string[], kwargs: Map): string { + renderPreviewNode(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode in the TinyMCE editor + console.log(`renderPreviewNode()`, pargs, kwargs); const ppairs = pargs.map((arg, i) => `data-parg${i}="${arg}"`); - const kwpairs = this.sortKWargs(Object.entries(kwargs)).map(([key, value]) => `data-kw-${key}=${escape(value)}`); + const kwpairs = this.sortKWargs(kwargs.entries()).map(([key, value]) => `data-kw-${key}=${ShortcodeHandle.escape(value)}`); const parts = [`class="mceNonEditable"`, `data-shortcode="${this.keyword}"`, ...ppairs, ...kwpairs]; - return `${this.renderShortcode(pargs, kwargs)}`; + return `${this.renderPreview(pargs, kwargs)}`; + } + + renderPreview(pargs: string[], kwargs: Map): string { + // The html string representation of the shortcode preview in the TinyMCE editor + // By default this is just the canonical text representation. This function will be overwritten by most subclasses. + return this.renderShortcode(pargs, kwargs); } openEditDialog(formApi: MenuItemInstanceApi | ContextFormInstanceApi) { // The default implementation for constructing the edit dialog for a generic shortcode const node = this.getNode(); - - const initialPargs = node !== null ? node.dataset.pargs.split(" ") : []; - while (initialPargs.length < this.minPargs) { - initialPargs.push(""); - } - - const prefix = "data-kw-"; - const initialKWargs = this.sortKWargs(Object.entries(node !== null ? node.dataset : {}).reduce((acc, pair) => { - if (pair[0].startsWith(prefix)) { - const keyword = pair[0].slice(prefix.length); - acc.push([keyword, pair[1]]); - } - return acc; - }, [])); + const [initialPargs, initialKWargs] = this.argsFromNode(node); initialPargs.push("8"); - initialKWargs.push(["test", "3"]) + initialKWargs.set("test", "3") const argumentItems: BodyComponentSpec[] = []; initialPargs.forEach((parg: string, i: number) => { @@ -149,7 +175,7 @@ class ShortcodeHandle { ], }); } - initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { + this.sortKWargs(initialKWargs.entries()).forEach(([keyword, value]: [string, string], i: number) => { if (this.kwargs === null) { argumentItems.push({ type: "bar", @@ -212,7 +238,7 @@ class ShortcodeHandle { ], initialData: { ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), - ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { + ...Object.fromEntries(Array.from(initialKWargs.entries()).reduce((acc, [keyword, value], i) => { if (this.kwargs === null) { return acc.concat([ [`kwarg${i}-name`, keyword], @@ -250,6 +276,8 @@ class ShortcodeHandle { if (acc[id][(which+1) % 2] !== null) { // Pair is complete! [key, value] = acc[id]; + // Normalize keys to dash-style + key = key.toLowerCase().replace(/\s+/g, "-"); acc[key] = value; delete acc[id]; } @@ -259,6 +287,7 @@ class ShortcodeHandle { }, {}) as unknown as FinalizedPairs; if (pargs.length <= this.minPargs || pargs.length >= this.maxPargs) { + // Invalid number of positional arguments, don't close dialog return; } api.close(); @@ -305,7 +334,7 @@ class ShortcodeHandle { position: "node", scope: "node", commands: [ - { + /*{ type: "contextformbutton", icon: this.editIcon, text: this.text(this.editText), @@ -329,7 +358,7 @@ class ShortcodeHandle { } closeContextToolbar(); }).bind(this), - }, + },*/ ], }); } @@ -340,9 +369,11 @@ class Registry { static #instance: Registry; handles: Map; + unknownHandleFactory: null | ((keyword: string) => ShortcodeHandle); private constructor() { this.handles = new Map(); + this.unknownHandleFactory = null; } public static get instance(): Registry { @@ -352,15 +383,23 @@ class Registry { return Registry.#instance; } + public static has(keyword: string): boolean { + return Registry.instance.handles.has(keyword); + } + + public static get(keyword: string): ShortcodeHandle | null { + return Registry.instance.handles.get(keyword); + } + public static register(handle: ShortcodeHandle) { - if (Registry.instance.handles.has(handle.keyword)) { - throw Error(`Keyword ${handle.keyword} already registered as ${Registry.instance.handles.get(handle.keyword)}`); + if (Registry.has(handle.keyword)) { + throw Error(`Keyword ${handle.keyword} already registered as ${Registry.get(handle.keyword)}`); } Registry.instance.handles.set(handle.keyword, handle); } public static unregister(handle: ShortcodeHandle | string): ShortcodeHandle { const keyword = handle instanceof ShortcodeHandle ? handle.keyword : handle; - const old_handle = Registry.instance.handles.get(keyword); + const old_handle = Registry.get(keyword); if (handle instanceof ShortcodeHandle && old_handle !== handle) { throw Error(`Keyword ${keyword} registered as a different handle: ${old_handle}`); } @@ -371,11 +410,28 @@ class Registry { Registry.instance.handles.clear(); } + public static setUnknownHandleFactory(factory: (keyword: string) => ShortcodeHandle) { + this.instance.unknownHandleFactory = factory; + } + public static setupAll(editor: Editor, parser: Parser) { Registry.instance.handles.forEach((value: ShortcodeHandle, key: string) => { value.setup(editor); - parser.register(value.renderPreview.bind(value), key, value.endword); + parser.register(value.renderPreviewNode.bind(value), key, value.endword); }); + if (this.instance.unknownHandleFactory !== null) { + parser.setUnknownHandlerFactory((keyword: string) => { + const handle = this.instance.unknownHandleFactory(keyword); + const fn = handle.renderPreviewNode.bind(handle); + Registry.register(handle); + handle.setup(editor); + parser.register(fn, keyword); + console.log(`created and registered dummy handler for ${keyword}`); + return fn; + }); + } else { + parser.setUnknownHandlerFactory(null); + } } } From 7724a10b0066b789c94aefafbd34a510ec2069ac Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Sun, 5 Oct 2025 19:40:33 +0200 Subject: [PATCH 05/26] WIP fix toolbar --- .../js/tinymce-plugins/shortcodes/utils.ts | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 1e79262a0b..6ef1340772 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -1,5 +1,5 @@ /// -import type { ContextFormInstanceApi, ContextFormButtonInstanceApi, ContextFormToggleButtonInstanceApi, DialogInstanceApi, DialogData, MenuItemInstanceApi, DialogSpec, BodyComponentSpec } from "../tinymce.d.ts"; +import type { ToolbarButtonInstanceApi, ContextFormInstanceApi, ContextFormButtonInstanceApi, ContextFormToggleButtonInstanceApi, DialogInstanceApi, DialogData, MenuItemInstanceApi, DialogSpec, BodyComponentSpec } from "../tinymce.d.ts"; import { Editor } from "tinymce"; import type { Parser } from "./shortcodes"; @@ -328,38 +328,35 @@ class ShortcodeHandle { onAction: this.openEditDialog.bind(this), }); + editor.ui.registry.addButton(`edit_shortcode_${this.keyword}`, { + text: this.text(this.editText), + tooltip: this.text(this.editText), + icon: this.editIcon, + onAction: ((api: ToolbarButtonInstanceApi) => { + this.openEditDialog(api); + closeContextToolbar(); + }).bind(this), + }); + + editor.ui.registry.addButton(`remove_shortcode_${this.keyword}`, { + text: this.text(this.removeText), + tooltip: this.text(this.removeText), + icon: this.removeIcon, + onAction: (() => { + const node = this.getNode(); + if (node) { + node.remove(); + } + closeContextToolbar(); + }).bind(this), + }); + // This form opens when a shortcode is currently selected with the cursor - editor.ui.registry.addContextForm(`shortcode_${this.keyword}_context_form`, { + editor.ui.registry.addContextToolbar(`shortcode_${this.keyword}_context_form`, { predicate: this.predicate.bind(this), position: "node", scope: "node", - commands: [ - /*{ - type: "contextformbutton", - icon: this.editIcon, - text: this.text(this.editText), - tooltip: this.text(this.editText), - primary: true, - onAction: ((formApi: ContextFormInstanceApi, api: ContextFormButtonInstanceApi) => { - this.openEditDialog(formApi); - closeContextToolbar(); - }).bind(this), - }, - { - type: "contextformbutton", - icon: this.removeIcon, - text: this.text(this.removeText), - tooltip: this.text(this.removeText), - primary: false, - onAction: (() => { - const node = this.getNode(); - if (node) { - node.remove(); - } - closeContextToolbar(); - }).bind(this), - },*/ - ], + items: `edit_shortcode_${this.keyword} remove_shortcode_${this.keyword}`, }); } } From ec1a7aae76770965a666f53e1b7a062aec49ebcf Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Sun, 5 Oct 2025 23:59:01 +0200 Subject: [PATCH 06/26] WIP fix dynamically adding/removing arguments --- .../js/tinymce-plugins/shortcodes/utils.ts | 187 +++++++++++++----- 1 file changed, 136 insertions(+), 51 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 6ef1340772..c6a947ed56 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -56,6 +56,8 @@ class ShortcodeHandle { get minPargs() { return this.pargs === null ? 0 : (typeof this.pargs === "number" ? this.pargs : this.pargs[1]); } + lastUnsavedPargs: string[] | null = null; + lastUnsavedKWargs: [string, string][] | null = null; text(text: TextDescriptor): string { // A helper method to render a string from either a fixed value or a dynamic function @@ -68,10 +70,8 @@ class ShortcodeHandle { predicate(node: Element): boolean { // A method determining whether a node in TinyMCE represents this shortcode // (e.g. whether the toolbar specific to this shortcode should be shown) - console.log(`predicate()`, node); if (!("dataset" in node)) return false; const dataset = (node as HTMLElement).dataset; - console.log(`dataset`, dataset, !!(dataset.shortcode), this.keyword, (dataset.shortcode == this.keyword), !this.keyword); return dataset.shortcode && (dataset.shortcode == this.keyword || !this.keyword); } @@ -92,6 +92,10 @@ class ShortcodeHandle { }); } + validate(pargs: string[], kwargs: {[key: string]: string}): boolean { + return pargs.length >= this.minPargs && pargs.length <= this.maxPargs; + } + argsFromNode(node: HTMLElement | null): [string[], Map] { let prefix = "parg"; const pargs = [...Object.entries(node !== null ? node.dataset : {})].reduce((acc, pair) => { @@ -142,14 +146,60 @@ class ShortcodeHandle { return this.renderShortcode(pargs, kwargs); } - openEditDialog(formApi: MenuItemInstanceApi | ContextFormInstanceApi) { - // The default implementation for constructing the edit dialog for a generic shortcode + reconstructArgsFromDialog(api: DialogInstanceApi): [string[], {[key: string]: string}, [string, string][]] { + // Reconstruct the positional and keyword arguments from the form data + const data = api.getData(); + const pargs = Object.entries(data).reduce((acc: string[], [key, value]) => { + const match = key.match(/^parg([0-9]+)$/); + if (match) { + acc[parseInt(match[1])] = value; + } + return acc; + }, []); + type TemporaryPairs = {[key: number]: [null | string, null | string]}; + type FinalizedPairs = {[key: string]: string}; + type OrderedPairs = [string, string][]; + const orderedKWargs: OrderedPairs = []; + const kwargs = Object.entries(data).reduce((acc: TemporaryPairs & FinalizedPairs, [key, value]) => { + // First piece together the names with the values again + const match = key.match(/^(kw-(.+)|kwarg([0-9]+)-(name|value))$/); + if (match) { + if (match[2]) { + acc[match[2]] = value; + } else { + const id = parseInt(match[3]); + const which = match[4] == "name" ? 0 : 1; + if (acc[id] === undefined) acc[id] = [null, null]; + acc[id][which] = value; + if (acc[id][(which+1) % 2] !== null) { + // Pair is complete! + [key, value] = acc[id]; + // Normalize keys to dash-style + // This is not its own overridable function because it is already automatically transformed + // to dash-style on the marker node representing the shortode to TinyMCE (this is how HTML attributes work) + // and to camelCase by the dataset API on JS side. + key = key.toLowerCase().replace(/\s+/g, "-"); + acc[key] = value; + orderedKWargs[id] = [key, value]; + delete acc[id]; + } + } + } + return acc; + }, {}) as unknown as FinalizedPairs; + return [pargs, kwargs, orderedKWargs]; + } + + openEditDialog() { const node = this.getNode(); const [initialPargs, initialKWargs] = this.argsFromNode(node); + this.lastUnsavedPargs = initialPargs; + this.lastUnsavedKWargs = this.sortKWargs(initialKWargs.entries()); + return this.displayEditDialog(initialPargs, this.lastUnsavedKWargs); + } - initialPargs.push("8"); - initialKWargs.set("test", "3") - + displayEditDialog(initialPargs: string[], initialKWargs: [string, string][]) { + // The default implementation for constructing the edit dialog for a generic shortcode const argumentItems: BodyComponentSpec[] = []; initialPargs.forEach((parg: string, i: number) => { argumentItems.push({ @@ -160,7 +210,16 @@ class ShortcodeHandle { }); if (this.minPargs != this.maxPargs) { argumentItems.push({ - type: "bar", + type: "htmlpanel", + html: `
+
+ +
+
+ +
+
`, + /*type: "bar", items: [ { type: "button", @@ -172,10 +231,10 @@ class ShortcodeHandle { text: "+", name: "parg-add", }, - ], + ],*/ }); } - this.sortKWargs(initialKWargs.entries()).forEach(([keyword, value]: [string, string], i: number) => { + initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { if (this.kwargs === null) { argumentItems.push({ type: "bar", @@ -205,6 +264,17 @@ class ShortcodeHandle { type: "bar", items: [ { + type: "htmlpanel", + html: `
+
+ +
+
+ +
+
`, + }, + /*{ type: "button", text: "–", name: "kwarg-remove", @@ -213,7 +283,7 @@ class ShortcodeHandle { type: "button", text: "+", name: "kwarg-add", - }, + },*/ ], }); } @@ -238,7 +308,7 @@ class ShortcodeHandle { ], initialData: { ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), - ...Object.fromEntries(Array.from(initialKWargs.entries()).reduce((acc, [keyword, value], i) => { + ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { if (this.kwargs === null) { return acc.concat([ [`kwarg${i}-name`, keyword], @@ -252,45 +322,16 @@ class ShortcodeHandle { }, [])), }, onSubmit: (api: DialogInstanceApi) => { - const data = api.getData(); - const pargs = Object.entries(data).reduce((acc: string[], [key, value]) => { - const match = key.match(/^parg([0-9]+)$/); - if (match) { - acc[parseInt(match[1])] = value; - } - return acc; - }, []); - type TemporaryPairs = {[key: number]: [null | string, null | string]}; - type FinalizedPairs = {[key: string]: string}; - const kwargs = Object.entries(data).reduce((acc: TemporaryPairs & FinalizedPairs, [key, value]) => { - // First piece together the names with the values again - const match = key.match(/^(kw-(.+)|kwarg([0-9]+)-(name|value))$/); - if (match) { - if (match[2]) { - acc[match[2]] = value; - } else { - const id = parseInt(match[3]); - const which = match[4] == "name" ? 0 : 1; - if (acc[id] === undefined) acc[id] = [null, null]; - acc[id][which] = value; - if (acc[id][(which+1) % 2] !== null) { - // Pair is complete! - [key, value] = acc[id]; - // Normalize keys to dash-style - key = key.toLowerCase().replace(/\s+/g, "-"); - acc[key] = value; - delete acc[id]; - } - } - } - return acc; - }, {}) as unknown as FinalizedPairs; + const [pargs, kwargs, orderedKWargs] = this.reconstructArgsFromDialog(api); + this.lastUnsavedPargs = pargs; + this.lastUnsavedKWargs = orderedKWargs; + + // Don't close the dialog if the arguments are not valid + if (!this.validate(pargs, kwargs)) return; - if (pargs.length <= this.minPargs || pargs.length >= this.maxPargs) { - // Invalid number of positional arguments, don't close dialog - return; - } api.close(); + this.lastUnsavedPargs = null; + this.lastUnsavedKWargs = null; // Either insert a new shortcode or update the existing one const node = this.getNode(); @@ -301,10 +342,54 @@ class ShortcodeHandle { this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); } }, - //onChange: updateDialog, + onChange: (api: DialogInstanceApi) => { + const [pargs, kwargs, orderedKWargs] = this.reconstructArgsFromDialog(api); + this.lastUnsavedPargs = pargs; + this.lastUnsavedKWargs = orderedKWargs; + }, }; console.log(`[${this.keyword}]`, this, dialogConfig); + setTimeout(() => { + const dialog = document.querySelector('.tox-dialog'); + const pargRemove = dialog.querySelector('#parg-remove'); + const pargAdd = dialog.querySelector('#parg-add'); + if (pargRemove) { + pargRemove.addEventListener("click", (() => { + if (this.lastUnsavedPargs === null) return; + this.lastUnsavedPargs.pop(); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + if (pargAdd) { + pargAdd.addEventListener("click", (() => { + if (this.lastUnsavedPargs === null) return; + this.lastUnsavedPargs.push(""); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + const kwargRemove = dialog.querySelector('#kwarg-remove'); + const kwargAdd = dialog.querySelector('#kwarg-add'); + if (kwargRemove) { + kwargRemove.addEventListener("click", (() => { + if (this.lastUnsavedKWargs === null) return; + this.lastUnsavedKWargs.pop(); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + if (kwargAdd) { + kwargAdd.addEventListener("click", (() => { + if (this.lastUnsavedKWargs === null) return; + this.lastUnsavedKWargs.push(["", ""]); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + }, 0); + return this.editor.windowManager.open(dialogConfig); } @@ -333,7 +418,7 @@ class ShortcodeHandle { tooltip: this.text(this.editText), icon: this.editIcon, onAction: ((api: ToolbarButtonInstanceApi) => { - this.openEditDialog(api); + this.openEditDialog(); closeContextToolbar(); }).bind(this), }); From 03b52d3870c5640c08a4e4b484ae3356ab402cfe Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 6 Oct 2025 01:20:14 +0200 Subject: [PATCH 07/26] =?UTF-8?q?WIP=20required/optional=20kwargs=20(TODO:?= =?UTF-8?q?=20ts=20set=20methods=20not=20recognized=20=E2=86=92=20update?= =?UTF-8?q?=20ts=20+=20node=3F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/tinymce-plugins/shortcodes/utils.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index c6a947ed56..def1f24c19 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -56,6 +56,24 @@ class ShortcodeHandle { get minPargs() { return this.pargs === null ? 0 : (typeof this.pargs === "number" ? this.pargs : this.pargs[1]); } + get requiredKWargs(): Set { + return new Set(this.kwargs.reduce((acc, key: string | [string, boolean]) => { + if (typeof key === "string") { + acc.push(key); + } else if (key[1]) { + acc.push(key[0]); + } + return acc; + }, [])); + } + get optionalKWargs(): Set { + return new Set(this.kwargs.reduce((acc, key: string | [string, boolean]) => { + if (!(typeof key === "string") && !key[1]) { + acc.push(key[0]); + } + return acc; + }, [])); + } lastUnsavedPargs: string[] | null = null; lastUnsavedKWargs: [string, string][] | null = null; @@ -93,7 +111,19 @@ class ShortcodeHandle { } validate(pargs: string[], kwargs: {[key: string]: string}): boolean { - return pargs.length >= this.minPargs && pargs.length <= this.maxPargs; + if (pargs.length < this.minPargs || pargs.length > this.maxPargs) return false; + // Positional arguments pass! + + if (this.kwargs === null) return true; + const keywords = new Set(Object.keys(kwargs)); + const requiredKWargs = this.requiredKWargs; + // Check if any required keyword arguments are missing + if (requiredKWargs.difference(keywords).size > 0) return false; + // Check if there are any keyword arguments that are not allowed + if (keywords.difference(requiredKWargs).difference(this.optionalKWargs).size > 0) return false; + + // All arguments pass! + return true; } argsFromNode(node: HTMLElement | null): [string[], Map] { From 7ac6934959c8bc771dae14fd542698f78c3f3d8d Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 6 Oct 2025 18:17:36 +0200 Subject: [PATCH 08/26] WIP enable polyfills from new ecma specs --- tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index a076ecc1fb..bcb97d634c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,8 @@ "target": "es6", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "module": "commonjs", + /* Specifies the set of built-in type definitions to include, with 'esnext' enabling latest ECMAScript features and 'dom.iterable' adding support for iterable DOM types. */ + "lib": ["esnext", "dom.iterable"], /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ "jsx": "react-jsx", /* Declares the module specifier to be used for importing the jsx and jsxs factory functions when using jsx as "react-jsx". */ From 080dc30c0469111d3ad925b820b94f8b3bb12401 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Tue, 7 Oct 2025 18:43:28 +0200 Subject: [PATCH 09/26] WIP fix arguments --- .../js/tinymce-plugins/shortcodes/contact.js | 3 +- .../js/tinymce-plugins/shortcodes/plugin.js | 2 +- .../js/tinymce-plugins/shortcodes/utils.ts | 123 ++++++++++++++---- 3 files changed, 99 insertions(+), 29 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js index d45c95a0e4..8db912e91d 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js @@ -6,7 +6,8 @@ class ContactHandle extends ShortcodeHandle { editIcon = "contact"; removeIcon = "remove"; - t() {} + pargs = [2, 8]; + kwargs = ["one", "two"]; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js index 8eefc32169..d5698fcdce 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/plugin.js @@ -4,7 +4,7 @@ import PageHandle from "./page"; import { Registry, ShortcodeHandle } from "./utils"; Registry.register(new PageHandle()); -//Registry.register(new ContactHandle()); +Registry.register(new ContactHandle()); function DummyHandleFactory(keyword) { const handle = new ShortcodeHandle(); diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index def1f24c19..9334afec4a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -17,19 +17,30 @@ import { Shortcode as parser } from "./shortcodes"; */ +class InfiniteSet extends Set { + // A way to model a set containing everything + size: number = Infinity; + + has(value: T): boolean { + return true; + } +} + + type TextDescriptor = string | ((self: ShortcodeHandle) => string); /* Positional arguments: - * - number: How many positional arguments have to be given (exactly, not more and not less) - * - [number, number]: Up to how many positional arguments CAN be given, and how many of those are required - * - null: Allow any number of positional arguments + * - number: How many positional arguments have to be given (exactly, not more and not less). + * - [number, number | null]: How many positional arguments are required, and up to how many CAN be given (Infinity or null means unbounded). + * - null: Allow any number of positional arguments. * Keyword arguments: - * - (string | [string, boolean])[]: List of all keyword arguments being accepted. - * If an item is given as a list where the second value is true, the keyword is required. - * Also serves as a canonical order normalizing the shortcode - * - null: Allow any keyword argument + * - (string | [string, boolean] | null)[]: List of all keyword arguments being accepted. + * If an item is given as a list where the second value is true, the keyword is required. + * Also serves as a canonical order normalizing the shortcode. + * If the list contains null, anything is accepted as optional argument. + * - null: Allow any keyword argument. */ -type PargsConstraint = number | [number, number] | null; -type KWargsDescriptor = (string | [string, boolean])[] | null; +type PargsConstraint = number | [number, number | null] | null; +type KWargsDescriptor = (string | [string, boolean] | null)[] | null; class ShortcodeHandle { @@ -51,24 +62,39 @@ class ShortcodeHandle { pargs: PargsConstraint = null; kwargs: KWargsDescriptor = null; get maxPargs() { - return this.pargs === null ? Infinity : (typeof this.pargs === "number" ? this.pargs : this.pargs[0]); + if (this.pargs === null) + return Infinity; + if (typeof this.pargs === "number") + return this.pargs; + else if (this.pargs[1]) + return Infinity; + else + this.pargs[0]; } get minPargs() { - return this.pargs === null ? 0 : (typeof this.pargs === "number" ? this.pargs : this.pargs[1]); + if (this.pargs === null) + return 0; + if (typeof this.pargs === "number") + return this.pargs; + else + this.pargs[0]; } get requiredKWargs(): Set { - return new Set(this.kwargs.reduce((acc, key: string | [string, boolean]) => { + if (this.kwargs === null) return new Set(); + return new Set(this.kwargs.reduce((acc, key: string | [string, boolean] | null) => { if (typeof key === "string") { acc.push(key); - } else if (key[1]) { + } else if (key !== null && key[1]) { acc.push(key[0]); } return acc; }, [])); } - get optionalKWargs(): Set { - return new Set(this.kwargs.reduce((acc, key: string | [string, boolean]) => { - if (!(typeof key === "string") && !key[1]) { + get optionalKWargs(): Set | InfiniteSet { + if (this.kwargs === null) return new InfiniteSet(); + const set = this.kwargs.includes(null) ? InfiniteSet : Set; + return new set(this.kwargs.reduce((acc, key: string | [string, boolean] | null) => { + if (key !== null && !(typeof key === "string") && !key[1]) { acc.push(key[0]); } return acc; @@ -88,7 +114,8 @@ class ShortcodeHandle { predicate(node: Element): boolean { // A method determining whether a node in TinyMCE represents this shortcode // (e.g. whether the toolbar specific to this shortcode should be shown) - if (!("dataset" in node)) return false; + if (!("dataset" in node)) + return false; const dataset = (node as HTMLElement).dataset; return dataset.shortcode && (dataset.shortcode == this.keyword || !this.keyword); } @@ -111,16 +138,22 @@ class ShortcodeHandle { } validate(pargs: string[], kwargs: {[key: string]: string}): boolean { - if (pargs.length < this.minPargs || pargs.length > this.maxPargs) return false; + if (pargs.length < this.minPargs || pargs.length > this.maxPargs) + return false; // Positional arguments pass! - if (this.kwargs === null) return true; + if (this.kwargs === null) + return true; const keywords = new Set(Object.keys(kwargs)); const requiredKWargs = this.requiredKWargs; // Check if any required keyword arguments are missing - if (requiredKWargs.difference(keywords).size > 0) return false; + if (requiredKWargs.difference(keywords).size > 0) + return false; // Check if there are any keyword arguments that are not allowed - if (keywords.difference(requiredKWargs).difference(this.optionalKWargs).size > 0) return false; + // InfiniteSet currently just says "yes I have that element" to every key it is asked about via .has(), and reports its size as Infinity. + // If .difference() ever stops relying on .has() and instead looks at the actual elements, this line will break. + if (keywords.difference(requiredKWargs).difference(this.optionalKWargs).size > 0) + return false; // All arguments pass! return true; @@ -154,6 +187,37 @@ class ShortcodeHandle { return [pargs, new Map(kwargs)]; } + truncateArgs(pargs: string[], kwargs: Map): [string[], Map] { + // Ensure the arguments fit the specification + let newPargs = [...pargs]; + const newKWargs = new Map(kwargs); + + // Ensure the correct number of positional arguments + if (newPargs.length > this.maxPargs) { + newPargs = newPargs.slice(0, this.maxPargs); + } + while (newPargs.length < this.minPargs) { + newPargs.push(""); + } + + // Ensure all required keywords exist + const requiredKWargs = this.requiredKWargs; + const optionalKWargs = this.optionalKWargs; + requiredKWargs.forEach(kwarg => { + if (!newKWargs.has(kwarg)) { + newKWargs.set(kwarg, ""); + } + }); + // Ensure no unallowed keywords exist + kwargs.forEach(kwarg => { + if (!requiredKWargs.has(kwarg) && !optionalKWargs.has(kwarg)) { + newKWargs.delete(kwarg); + } + }); + + return [newPargs, newKWargs]; + } + renderShortcode(pargs: string[], kwargs: Map): string { // The canonical text representation of the shortcode const pairs = this.sortKWargs(kwargs.entries()).map(pair => pair.map(ShortcodeHandle.escape).join("=")); @@ -222,7 +286,7 @@ class ShortcodeHandle { openEditDialog() { const node = this.getNode(); - const [initialPargs, initialKWargs] = this.argsFromNode(node); + const [initialPargs, initialKWargs] = this.truncateArgs(...this.argsFromNode(node)); this.lastUnsavedPargs = initialPargs; this.lastUnsavedKWargs = this.sortKWargs(initialKWargs.entries()); return this.displayEditDialog(initialPargs, this.lastUnsavedKWargs); @@ -357,7 +421,8 @@ class ShortcodeHandle { this.lastUnsavedKWargs = orderedKWargs; // Don't close the dialog if the arguments are not valid - if (!this.validate(pargs, kwargs)) return; + if (!this.validate(pargs, kwargs)) + return; api.close(); this.lastUnsavedPargs = null; @@ -386,7 +451,8 @@ class ShortcodeHandle { const pargAdd = dialog.querySelector('#parg-add'); if (pargRemove) { pargRemove.addEventListener("click", (() => { - if (this.lastUnsavedPargs === null) return; + if (this.lastUnsavedPargs === null) + return; this.lastUnsavedPargs.pop(); this.editor.windowManager.close(); this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); @@ -394,7 +460,8 @@ class ShortcodeHandle { } if (pargAdd) { pargAdd.addEventListener("click", (() => { - if (this.lastUnsavedPargs === null) return; + if (this.lastUnsavedPargs === null) + return; this.lastUnsavedPargs.push(""); this.editor.windowManager.close(); this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); @@ -404,7 +471,8 @@ class ShortcodeHandle { const kwargAdd = dialog.querySelector('#kwarg-add'); if (kwargRemove) { kwargRemove.addEventListener("click", (() => { - if (this.lastUnsavedKWargs === null) return; + if (this.lastUnsavedKWargs === null) + return; this.lastUnsavedKWargs.pop(); this.editor.windowManager.close(); this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); @@ -412,7 +480,8 @@ class ShortcodeHandle { } if (kwargAdd) { kwargAdd.addEventListener("click", (() => { - if (this.lastUnsavedKWargs === null) return; + if (this.lastUnsavedKWargs === null) + return; this.lastUnsavedKWargs.push(["", ""]); this.editor.windowManager.close(); this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); From e9830963a83af9fd085187b9d250d23a27e07645 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Thu, 9 Oct 2025 19:12:43 +0200 Subject: [PATCH 10/26] WIP --- .../js/tinymce-plugins/shortcodes/utils.ts | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 9334afec4a..5fb5c631ba 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -24,6 +24,18 @@ class InfiniteSet extends Set { has(value: T): boolean { return true; } + union(other) { + return new InfiniteSet(super.union(other)); + } + difference(other) { + return new InfiniteSet(super.difference(other)); + } + symmetricDifference(other) { + return new InfiniteSet(super.symmetricDifference(other)); + } + intersection(other) { + return new InfiniteSet(super.intersection(other)); + } } @@ -210,7 +222,7 @@ class ShortcodeHandle { }); // Ensure no unallowed keywords exist kwargs.forEach(kwarg => { - if (!requiredKWargs.has(kwarg) && !optionalKWargs.has(kwarg)) { + if (!(requiredKWargs.has(kwarg) || optionalKWargs.has(kwarg))) { newKWargs.delete(kwarg); } }); @@ -329,7 +341,7 @@ class ShortcodeHandle { }); } initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { - if (this.kwargs === null) { + if (this.kwargs === null || this.kwargs.includes(null)) { argumentItems.push({ type: "bar", items: [ @@ -353,7 +365,7 @@ class ShortcodeHandle { }); } }); - if (this.kwargs === null) { + if (this.kwargs === null || this.kwargs.includes(null)) { argumentItems.push({ type: "bar", items: [ @@ -451,7 +463,7 @@ class ShortcodeHandle { const pargAdd = dialog.querySelector('#parg-add'); if (pargRemove) { pargRemove.addEventListener("click", (() => { - if (this.lastUnsavedPargs === null) + if (this.lastUnsavedPargs === null || this.lastUnsavedPargs.length <= this.minPargs) return; this.lastUnsavedPargs.pop(); this.editor.windowManager.close(); @@ -460,7 +472,7 @@ class ShortcodeHandle { } if (pargAdd) { pargAdd.addEventListener("click", (() => { - if (this.lastUnsavedPargs === null) + if (this.lastUnsavedPargs === null || this.lastUnsavedPargs.length >= this.maxPargs) return; this.lastUnsavedPargs.push(""); this.editor.windowManager.close(); @@ -473,7 +485,16 @@ class ShortcodeHandle { kwargRemove.addEventListener("click", (() => { if (this.lastUnsavedKWargs === null) return; - this.lastUnsavedKWargs.pop(); + const requiredKWargs = this.requiredKWargs; + // Throw away the last keyword that is not required + for (let i = this.lastUnsavedKWargs.length-1; i >= 0; i--) { + if (requiredKWargs.has(e[0])) + continue; + const beforeThis = this.lastUnsavedKWargs.slice(0, i); + const afterThis = this.lastUnsavedKWargs.slice(i+1, this.lastUnsavedKWargs.length); + this.lastUnsavedKWargs = beforeThis.concat(afterThis); + break; + }; this.editor.windowManager.close(); this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); }).bind(this)); @@ -482,7 +503,31 @@ class ShortcodeHandle { kwargAdd.addEventListener("click", (() => { if (this.lastUnsavedKWargs === null) return; - this.lastUnsavedKWargs.push(["", ""]); + // A flat list of known keywords in canonical order + const order = this.kwargs === null ? [] : this.kwargs.map(([key, required]) => key); + function index(x: string): number { + // Determine the canonical position of the keyword + const i = order.indexOf(x); + if (i == -1) return Infinity; // If the keyword is unknown, sort it last + return i; + } + const requiredKWargs = this.requiredKWargs; + const keys = new Set(this.lastUnsavedKWargs.map(([key, value]) => key)); + const missingRequired = requiredKWargs.difference(keys); + let key; + if (missingRequired.size > 0) { + // Somehow, required keywords are missing. Add the first one by canonical order + key = Array.from(missingRequired).sort((a, b) => index(a) - index(b)).reverse()[0]; + } else { + const optionalKWargs = this.optionalKWargs; + const missingOptional = optionalKWargs.difference(keys); + if (missingOptional.size == 0) + return; // There are no arguments left to add + // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty + key = Array.from(missingOptional).sort((a, b) => index(a) - index(b)).reverse()[0] || ""; + } + // Finally, actually append the key value pair and retrigger the dialog + this.lastUnsavedKWargs.push([key, ""]); this.editor.windowManager.close(); this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); }).bind(this)); From fd333b56b17c4e063ac84dc9780516b0308346fa Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Fri, 10 Oct 2025 21:18:56 +0200 Subject: [PATCH 11/26] WIP fix some kwarg stuff --- .../js/tinymce-plugins/shortcodes/contact.js | 2 +- .../js/tinymce-plugins/shortcodes/utils.ts | 94 +++++++------------ 2 files changed, 37 insertions(+), 59 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js index 8db912e91d..39147b9371 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js @@ -7,7 +7,7 @@ class ContactHandle extends ShortcodeHandle { removeIcon = "remove"; pargs = [2, 8]; - kwargs = ["one", "two"]; + kwargs = ["one", "two", null]; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 5fb5c631ba..841cc3f983 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -17,28 +17,6 @@ import { Shortcode as parser } from "./shortcodes"; */ -class InfiniteSet extends Set { - // A way to model a set containing everything - size: number = Infinity; - - has(value: T): boolean { - return true; - } - union(other) { - return new InfiniteSet(super.union(other)); - } - difference(other) { - return new InfiniteSet(super.difference(other)); - } - symmetricDifference(other) { - return new InfiniteSet(super.symmetricDifference(other)); - } - intersection(other) { - return new InfiniteSet(super.intersection(other)); - } -} - - type TextDescriptor = string | ((self: ShortcodeHandle) => string); /* Positional arguments: * - number: How many positional arguments have to be given (exactly, not more and not less). @@ -78,10 +56,10 @@ class ShortcodeHandle { return Infinity; if (typeof this.pargs === "number") return this.pargs; - else if (this.pargs[1]) + else if (this.pargs[1] === null) return Infinity; else - this.pargs[0]; + return this.pargs[1]; } get minPargs() { if (this.pargs === null) @@ -89,11 +67,11 @@ class ShortcodeHandle { if (typeof this.pargs === "number") return this.pargs; else - this.pargs[0]; + return this.pargs[0]; } get requiredKWargs(): Set { if (this.kwargs === null) return new Set(); - return new Set(this.kwargs.reduce((acc, key: string | [string, boolean] | null) => { + return new Set(this.kwargs.filter(kw => kw !== null).reduce((acc, key: string | [string, boolean] | null) => { if (typeof key === "string") { acc.push(key); } else if (key !== null && key[1]) { @@ -102,16 +80,20 @@ class ShortcodeHandle { return acc; }, [])); } - get optionalKWargs(): Set | InfiniteSet { - if (this.kwargs === null) return new InfiniteSet(); - const set = this.kwargs.includes(null) ? InfiniteSet : Set; - return new set(this.kwargs.reduce((acc, key: string | [string, boolean] | null) => { + get optionalKWargs(): Set { + if (this.kwargs === null) return new Set(); + return new Set(this.kwargs.filter(kw => kw !== null).reduce((acc, key: string | [string, boolean] | null) => { if (key !== null && !(typeof key === "string") && !key[1]) { acc.push(key[0]); } return acc; }, [])); } + get acceptingArbitraryKWargs(): boolean { + if (this.kwargs === null) return true; + if (this.kwargs.includes(null)) return true; + return false; + } lastUnsavedPargs: string[] | null = null; lastUnsavedKWargs: [string, string][] | null = null; @@ -140,7 +122,7 @@ class ShortcodeHandle { sortKWargs(kwpairs: Iterable<[string, string]> | [string, string][]): [string, string][] { // A helper method determining a canonical order for keyword arguments - const order = this.kwargs !== null ? this.kwargs.map(kw => typeof kw === "string" ? kw : kw[0]) : []; + const order = this.kwargs !== null ? this.kwargs.filter(kw => kw !== null).map(kw => typeof kw === "string" ? kw : kw[0]) : []; if (!(kwpairs instanceof Array)) kwpairs = Array.from(kwpairs); return (kwpairs as Array<[string, string]>).sort((a, b) => { const aPos = order.includes(a[0]) ? order.indexOf(a[0]) : order.length; @@ -162,9 +144,7 @@ class ShortcodeHandle { if (requiredKWargs.difference(keywords).size > 0) return false; // Check if there are any keyword arguments that are not allowed - // InfiniteSet currently just says "yes I have that element" to every key it is asked about via .has(), and reports its size as Infinity. - // If .difference() ever stops relying on .has() and instead looks at the actual elements, this line will break. - if (keywords.difference(requiredKWargs).difference(this.optionalKWargs).size > 0) + if (!this.acceptingArbitraryKWargs && keywords.difference(requiredKWargs).difference(this.optionalKWargs).size > 0) return false; // All arguments pass! @@ -220,13 +200,16 @@ class ShortcodeHandle { newKWargs.set(kwarg, ""); } }); - // Ensure no unallowed keywords exist - kwargs.forEach(kwarg => { - if (!(requiredKWargs.has(kwarg) || optionalKWargs.has(kwarg))) { - newKWargs.delete(kwarg); - } - }); + if (!this.acceptingArbitraryKWargs) { + // Ensure no unallowed keywords exist + kwargs.forEach((v, kwarg) => { + if (!(requiredKWargs.has(kwarg) || optionalKWargs.has(kwarg))) { + newKWargs.delete(kwarg); + } + }); + } + console.log(`truncateArgs() →`, newKWargs); return [newPargs, newKWargs]; } @@ -341,7 +324,7 @@ class ShortcodeHandle { }); } initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { - if (this.kwargs === null || this.kwargs.includes(null)) { + if (this.acceptingArbitraryKWargs) { argumentItems.push({ type: "bar", items: [ @@ -365,7 +348,7 @@ class ShortcodeHandle { }); } }); - if (this.kwargs === null || this.kwargs.includes(null)) { + if (this.acceptingArbitraryKWargs) { argumentItems.push({ type: "bar", items: [ @@ -380,16 +363,6 @@ class ShortcodeHandle { `, }, - /*{ - type: "button", - text: "–", - name: "kwarg-remove", - }, - { - type: "button", - text: "+", - name: "kwarg-add", - },*/ ], }); } @@ -483,12 +456,13 @@ class ShortcodeHandle { const kwargAdd = dialog.querySelector('#kwarg-add'); if (kwargRemove) { kwargRemove.addEventListener("click", (() => { + console.log(`kwargRemove() this.lastUnsavedKWargs === null ? ${this.lastUnsavedKWargs === null}`); if (this.lastUnsavedKWargs === null) return; const requiredKWargs = this.requiredKWargs; // Throw away the last keyword that is not required for (let i = this.lastUnsavedKWargs.length-1; i >= 0; i--) { - if (requiredKWargs.has(e[0])) + if (requiredKWargs.has(this.lastUnsavedKWargs[i][0])) continue; const beforeThis = this.lastUnsavedKWargs.slice(0, i); const afterThis = this.lastUnsavedKWargs.slice(i+1, this.lastUnsavedKWargs.length); @@ -501,10 +475,11 @@ class ShortcodeHandle { } if (kwargAdd) { kwargAdd.addEventListener("click", (() => { + console.log(`kwargAdd() this.lastUnsavedKWargs === null ? ${this.lastUnsavedKWargs === null}`); if (this.lastUnsavedKWargs === null) return; // A flat list of known keywords in canonical order - const order = this.kwargs === null ? [] : this.kwargs.map(([key, required]) => key); + const order = this.kwargs === null ? [] : this.kwargs.filter(kw => kw !== null).map(([key, required]) => key); function index(x: string): number { // Determine the canonical position of the keyword const i = order.indexOf(x); @@ -521,10 +496,13 @@ class ShortcodeHandle { } else { const optionalKWargs = this.optionalKWargs; const missingOptional = optionalKWargs.difference(keys); - if (missingOptional.size == 0) - return; // There are no arguments left to add - // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty - key = Array.from(missingOptional).sort((a, b) => index(a) - index(b)).reverse()[0] || ""; + if (missingOptional.size > 0) { + // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty + key = Array.from(missingOptional).sort((a, b) => index(a) - index(b)).reverse()[0] || ""; + } else if (this.acceptingArbitraryKWargs) + key = ""; + else + return; // There are no arguments left to add, stop without doing anything } // Finally, actually append the key value pair and retrigger the dialog this.lastUnsavedKWargs.push([key, ""]); From d9f87d727ec30108d7738712c9fdbafdb8ed5005 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Sun, 12 Oct 2025 18:25:26 +0200 Subject: [PATCH 12/26] WIP fix kwargs handling, disable add/remove buttons accordingly --- .../js/tinymce-plugins/shortcodes/contact.js | 2 +- .../tinymce-plugins/shortcodes/shortcodes.ts | 24 ++++---- .../js/tinymce-plugins/shortcodes/utils.ts | 61 ++++++++----------- 3 files changed, 40 insertions(+), 47 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js index 39147b9371..d1826dfe50 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js @@ -7,7 +7,7 @@ class ContactHandle extends ShortcodeHandle { removeIcon = "remove"; pargs = [2, 8]; - kwargs = ["one", "two", null]; + kwargs = ["one", ["etc", false], "two", ["opt", false]]; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts index f5287472f8..15ffd7a78e 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts @@ -5,7 +5,7 @@ // Globally-registered handler functions indexed by keyword. -const global_keywords = new Map, context: any, content?: string) => string, string]>(); +const global_keywords = new Map, context: any, content?: string) => string, string]>(); // The set of all end-words for globally-registered block-scoped shortcodes. @@ -15,7 +15,7 @@ const global_endwords = new Set(); // Decorator function for globally registering shortcode handlers. function register(keyword: string, endword: string) { - function register_function(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string) { + function register_function(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string) { global_keywords.set(keyword, [func, endword]); if (endword) { global_endwords.add(endword); @@ -109,12 +109,12 @@ class Shortcode extends ASTNode { (\S+) `.replace(/\s+/g, ""), "g"); - handler: (pargs: string[], kwargs: Map, context: any, content?: string) => string; + handler: (pargs: string[], kwargs: Map, context: any, content?: string) => string; pargs: string[]; - kwargs: Map; + kwargs: Map; children: ASTNode[]; - constructor(token: Token, handler_function: (pargs: string[], kwargs: Map, context: any, content?: string) => string) { + constructor(token: Token, handler_function: (pargs: string[], kwargs: Map, context: any, content?: string) => string) { super(); this.token = token; this.handler = handler_function; @@ -122,9 +122,9 @@ class Shortcode extends ASTNode { this.children = []; } - parse_args(argstring: string): [string[], Map] { + parse_args(argstring: string): [string[], Map] { const pargs: string[] = []; - const kwargs = new Map(); + const kwargs = new Map(); for (const match of argstring.matchAll(this.re_args)) { if (match[2] || match[5]) { const key = match[1] || match[5]; @@ -206,22 +206,22 @@ class Parser { start: string; end: string; esc_start: string; - keywords: Map, context: any, content?: string) => string, string]>; + keywords: Map, context: any, content?: string) => string, string]>; endwords: Set; ignore_unknown: boolean; - unknownHandlerFactory: (keyword: string) => (pargs: string[], kwargs: Map, context: any, content?: string) => string; // patched in + unknownHandlerFactory: (keyword: string) => (pargs: string[], kwargs: Map, context: any, content?: string) => string; // patched in constructor(start: string = '[%', end: string = '%]', esc: string = '\\', inherit_globals: boolean = true, ignore_unknown: boolean = false) { this.start = start; this.end = end; this.esc_start = esc + start; - this.keywords = new Map, context: any, content?: string) => string, string]>(inherit_globals ? global_keywords : null); + this.keywords = new Map, context: any, content?: string) => string, string]>(inherit_globals ? global_keywords : null); this.endwords = new Set(inherit_globals ? global_endwords : null); this.ignore_unknown = ignore_unknown; this.unknownHandlerFactory = null; // patched in } - register(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string, keyword: string, endword: string = null) { + register(func: (pargs: string[], kwargs: Map, context: any, content?: string) => string, keyword: string, endword: string = null) { this.keywords.set(keyword, [func, endword]); if (endword) { this.endwords.add(endword); @@ -229,7 +229,7 @@ class Parser { } // patched in - setUnknownHandlerFactory(func: (keyword: string) => (pargs: string[], kwargs: Map, context: any, content?: string) => string) { + setUnknownHandlerFactory(func: (keyword: string) => (pargs: string[], kwargs: Map, context: any, content?: string) => string) { this.unknownHandlerFactory = func; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 841cc3f983..982f02386a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -45,7 +45,8 @@ class ShortcodeHandle { removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; removeIcon: string = "unlink"; - static escape(str: string): string { + static escape(str: string | undefined): string { + if (!str) return '""'; return str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; } @@ -151,7 +152,7 @@ class ShortcodeHandle { return true; } - argsFromNode(node: HTMLElement | null): [string[], Map] { + argsFromNode(node: HTMLElement | null): [string[], Map] { let prefix = "parg"; const pargs = [...Object.entries(node !== null ? node.dataset : {})].reduce((acc, pair) => { if (pair[0].startsWith(prefix)) { @@ -179,7 +180,7 @@ class ShortcodeHandle { return [pargs, new Map(kwargs)]; } - truncateArgs(pargs: string[], kwargs: Map): [string[], Map] { + truncateArgs(pargs: string[], kwargs: Map): [string[], Map] { // Ensure the arguments fit the specification let newPargs = [...pargs]; const newKWargs = new Map(kwargs); @@ -209,27 +210,25 @@ class ShortcodeHandle { }); } - console.log(`truncateArgs() →`, newKWargs); return [newPargs, newKWargs]; } - renderShortcode(pargs: string[], kwargs: Map): string { + renderShortcode(pargs: string[], kwargs: Map): string { // The canonical text representation of the shortcode const pairs = this.sortKWargs(kwargs.entries()).map(pair => pair.map(ShortcodeHandle.escape).join("=")); const parts = [ShortcodeHandle.escape(this.keyword), ...pargs.map(ShortcodeHandle.escape), ...pairs]; return `[${parts.join(" ")}]`; } - renderPreviewNode(pargs: string[], kwargs: Map): string { + renderPreviewNode(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode in the TinyMCE editor - console.log(`renderPreviewNode()`, pargs, kwargs); const ppairs = pargs.map((arg, i) => `data-parg${i}="${arg}"`); const kwpairs = this.sortKWargs(kwargs.entries()).map(([key, value]) => `data-kw-${key}=${ShortcodeHandle.escape(value)}`); const parts = [`class="mceNonEditable"`, `data-shortcode="${this.keyword}"`, ...ppairs, ...kwpairs]; return `${this.renderPreview(pargs, kwargs)}`; } - renderPreview(pargs: string[], kwargs: Map): string { + renderPreview(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode preview in the TinyMCE editor // By default this is just the canonical text representation. This function will be overwritten by most subclasses. return this.renderShortcode(pargs, kwargs); @@ -302,25 +301,14 @@ class ShortcodeHandle { type: "htmlpanel", html: `
- +
- +
-
`, - /*type: "bar", - items: [ - { - type: "button", - text: "–", - name: "parg-remove", - }, - { - type: "button", - text: "+", - name: "parg-add", - }, - ],*/ + `.replace(/\s+/g, " "), }); } initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { @@ -348,7 +336,12 @@ class ShortcodeHandle { }); } }); - if (this.acceptingArbitraryKWargs) { + const optionalKWargs = this.optionalKWargs; + if (this.acceptingArbitraryKWargs || optionalKWargs.size > 0) { + const keys = new Set(initialKWargs.map(([key, value]) => key)); + const missingRequired = this.requiredKWargs.difference(keys); + const missingOptional = optionalKWargs.difference(keys); + const givenOptional = optionalKWargs.intersection(keys); argumentItems.push({ type: "bar", items: [ @@ -356,12 +349,14 @@ class ShortcodeHandle { type: "htmlpanel", html: `
- +
- +
-
`, + `.replace(/\s+/g, " "), }, ], }); @@ -456,7 +451,6 @@ class ShortcodeHandle { const kwargAdd = dialog.querySelector('#kwarg-add'); if (kwargRemove) { kwargRemove.addEventListener("click", (() => { - console.log(`kwargRemove() this.lastUnsavedKWargs === null ? ${this.lastUnsavedKWargs === null}`); if (this.lastUnsavedKWargs === null) return; const requiredKWargs = this.requiredKWargs; @@ -475,11 +469,10 @@ class ShortcodeHandle { } if (kwargAdd) { kwargAdd.addEventListener("click", (() => { - console.log(`kwargAdd() this.lastUnsavedKWargs === null ? ${this.lastUnsavedKWargs === null}`); if (this.lastUnsavedKWargs === null) return; // A flat list of known keywords in canonical order - const order = this.kwargs === null ? [] : this.kwargs.filter(kw => kw !== null).map(([key, required]) => key); + const order = this.kwargs !== null ? this.kwargs.filter(kw => kw !== null).map(kw => typeof kw === "string" ? kw : kw[0]) : []; function index(x: string): number { // Determine the canonical position of the keyword const i = order.indexOf(x); @@ -492,17 +485,17 @@ class ShortcodeHandle { let key; if (missingRequired.size > 0) { // Somehow, required keywords are missing. Add the first one by canonical order - key = Array.from(missingRequired).sort((a, b) => index(a) - index(b)).reverse()[0]; + key = Array.from(missingRequired).sort((a, b) => index(a) - index(b))[0]; } else { const optionalKWargs = this.optionalKWargs; const missingOptional = optionalKWargs.difference(keys); if (missingOptional.size > 0) { // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty - key = Array.from(missingOptional).sort((a, b) => index(a) - index(b)).reverse()[0] || ""; + key = Array.from(missingOptional).sort((a, b) => index(a) - index(b))[0] || ""; } else if (this.acceptingArbitraryKWargs) key = ""; else - return; // There are no arguments left to add, stop without doing anything + return; // There are no arguments left to add, immediately stop without doing anything } // Finally, actually append the key value pair and retrigger the dialog this.lastUnsavedKWargs.push([key, ""]); From 3f78f3f7fe238e5404287b24670ad2420cfb5ccc Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Wed, 15 Oct 2025 16:06:52 +0200 Subject: [PATCH 13/26] WIP introduce symbol to define acceppting arbitrary arguments --- .../js/tinymce-plugins/shortcodes/utils.ts | 116 +++++++++++------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 982f02386a..0dff3f0dbc 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -17,20 +17,23 @@ import { Shortcode as parser } from "./shortcodes"; */ +const AcceptArbitraryArguments = Symbol("AcceptArbitraryArguments"); +type ARBITRARY = typeof AcceptArbitraryArguments; + type TextDescriptor = string | ((self: ShortcodeHandle) => string); -/* Positional arguments: - * - number: How many positional arguments have to be given (exactly, not more and not less). - * - [number, number | null]: How many positional arguments are required, and up to how many CAN be given (Infinity or null means unbounded). - * - null: Allow any number of positional arguments. - * Keyword arguments: - * - (string | [string, boolean] | null)[]: List of all keyword arguments being accepted. - * If an item is given as a list where the second value is true, the keyword is required. - * Also serves as a canonical order normalizing the shortcode. - * If the list contains null, anything is accepted as optional argument. - * - null: Allow any keyword argument. - */ -type PargsConstraint = number | [number, number | null] | null; -type KWargsDescriptor = (string | [string, boolean] | null)[] | null; + +type SingleParg = null // Just to occupy this index. + | string // Name for the positional argument. + | [string, string]; // Name and description of the positional argument. +type PargsConstraint = ARBITRARY // Allow any number of positional arguments. + | number // How many positional arguments have to be given (exactly, not more and not less). + | [number, number | ARBITRARY] // How many positional arguments are required, and up to how many CAN be given (Infinity or null means unbounded). + | [SingleParg[], SingleParg[] | [...SingleParg[], ARBITRARY] | ARBITRARY]; // The list of the required and the list of optional positional arguments. +type SingleKWarg = string // Keyword (required) + | [string, boolean?, string?]; // Keyword, whether it is required (default: not required) and a description of the argument. +type KWargsDescriptor = ARBITRARY // Allow any keyword argument. + | SingleKWarg[] // List of all keyword arguments being accepted. Also serves as a canonical order normalizing the shortcode. + | [...SingleKWarg[], ARBITRARY]; // If the last element is AcceptArbitraryArguments, anything is accepted as optional argument. class ShortcodeHandle { @@ -50,50 +53,79 @@ class ShortcodeHandle { return str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; } - pargs: PargsConstraint = null; - kwargs: KWargsDescriptor = null; - get maxPargs() { - if (this.pargs === null) + pargs: PargsConstraint = AcceptArbitraryArguments; + kwargs: KWargsDescriptor = AcceptArbitraryArguments; + get maxPargs(): number { + if (this.pargs === AcceptArbitraryArguments) return Infinity; if (typeof this.pargs === "number") return this.pargs; - else if (this.pargs[1] === null) - return Infinity; - else - return this.pargs[1]; + else if (this.pargs.length > 0 && typeof this.pargs[0] === "number") { + // [min, max] style + if (this.pargs[1] === AcceptArbitraryArguments) + return Infinity; + else + return this.pargs[1] as number; + } else { + // Listing all individual arguments style + if (this.pargs.includes(AcceptArbitraryArguments)) + return Infinity; + else + return this.pargs.length; + } } - get minPargs() { - if (this.pargs === null) + get minPargs(): number { + if (this.pargs === AcceptArbitraryArguments) return 0; if (typeof this.pargs === "number") return this.pargs; - else + else if (this.pargs.length > 0 && typeof this.pargs[0] === "number") { + // [min, max] style return this.pargs[0]; + } else { + // Listing all individual arguments style + if (this.pargs.includes(AcceptArbitraryArguments)) + return Infinity; + else + return this.pargs.length; + } } get requiredKWargs(): Set { - if (this.kwargs === null) return new Set(); - return new Set(this.kwargs.filter(kw => kw !== null).reduce((acc, key: string | [string, boolean] | null) => { + if (this.kwargs === AcceptArbitraryArguments) return new Set(); + const known = this.kwargs.filter(kw => kw !== AcceptArbitraryArguments) as SingleKWarg[]; + const required = known.reduce((acc: string[], key: SingleKWarg) => { if (typeof key === "string") { acc.push(key); - } else if (key !== null && key[1]) { + } else if (key.length > 1 && key[1]) { acc.push(key[0]); } return acc; - }, [])); + }, []); + return new Set(required); } get optionalKWargs(): Set { - if (this.kwargs === null) return new Set(); - return new Set(this.kwargs.filter(kw => kw !== null).reduce((acc, key: string | [string, boolean] | null) => { - if (key !== null && !(typeof key === "string") && !key[1]) { + if (this.kwargs === AcceptArbitraryArguments) return new Set(); + const known = this.kwargs.filter(kw => kw !== AcceptArbitraryArguments) as SingleKWarg[]; + const optional = known.reduce((acc, key: SingleKWarg) => { + if (!(typeof key === "string") && !(key.length > 1 && key[1])) { acc.push(key[0]); } return acc; - }, [])); + }, []); + return new Set(optional); } get acceptingArbitraryKWargs(): boolean { - if (this.kwargs === null) return true; - if (this.kwargs.includes(null)) return true; - return false; + if (this.kwargs === AcceptArbitraryArguments) + return true; + return (this.kwargs as [...SingleKWarg[], ARBITRARY]) // Wrong type, it might also not contain AcceptArbitraryArguments, but this way typescript doesn't complain + .includes(AcceptArbitraryArguments); // This would make perfect sense to me even if kwargs is regarded as SingleKWarg[] | [...SingleKWarg[], ARBITRARY] + } + get kwargsOrder(): string[] { + if (this.kwargs === AcceptArbitraryArguments) + return []; + const kwargs = this.kwargs.filter(kw => kw !== AcceptArbitraryArguments) as SingleKWarg[] + const keywords = kwargs.map((kw: SingleKWarg) => typeof kw === "string" ? kw : kw[0]); + return keywords; } lastUnsavedPargs: string[] | null = null; lastUnsavedKWargs: [string, string][] | null = null; @@ -123,7 +155,7 @@ class ShortcodeHandle { sortKWargs(kwpairs: Iterable<[string, string]> | [string, string][]): [string, string][] { // A helper method determining a canonical order for keyword arguments - const order = this.kwargs !== null ? this.kwargs.filter(kw => kw !== null).map(kw => typeof kw === "string" ? kw : kw[0]) : []; + const order = this.kwargsOrder; if (!(kwpairs instanceof Array)) kwpairs = Array.from(kwpairs); return (kwpairs as Array<[string, string]>).sort((a, b) => { const aPos = order.includes(a[0]) ? order.indexOf(a[0]) : order.length; @@ -137,8 +169,8 @@ class ShortcodeHandle { return false; // Positional arguments pass! - if (this.kwargs === null) - return true; + if (this.kwargs === AcceptArbitraryArguments) + return true; // Early exit if we don't define any required keyword arguments and accept everything const keywords = new Set(Object.keys(kwargs)); const requiredKWargs = this.requiredKWargs; // Check if any required keyword arguments are missing @@ -222,7 +254,7 @@ class ShortcodeHandle { renderPreviewNode(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode in the TinyMCE editor - const ppairs = pargs.map((arg, i) => `data-parg${i}="${arg}"`); + const ppairs = pargs.map((arg, i) => `data-parg${i}=${ShortcodeHandle.escape(arg)}`); const kwpairs = this.sortKWargs(kwargs.entries()).map(([key, value]) => `data-kw-${key}=${ShortcodeHandle.escape(value)}`); const parts = [`class="mceNonEditable"`, `data-shortcode="${this.keyword}"`, ...ppairs, ...kwpairs]; return `${this.renderPreview(pargs, kwargs)}`; @@ -383,7 +415,7 @@ class ShortcodeHandle { initialData: { ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { - if (this.kwargs === null) { + if (this.kwargs === AcceptArbitraryArguments) { return acc.concat([ [`kwarg${i}-name`, keyword], [`kwarg${i}-value`, value], @@ -472,7 +504,7 @@ class ShortcodeHandle { if (this.lastUnsavedKWargs === null) return; // A flat list of known keywords in canonical order - const order = this.kwargs !== null ? this.kwargs.filter(kw => kw !== null).map(kw => typeof kw === "string" ? kw : kw[0]) : []; + const order = this.kwargsOrder; function index(x: string): number { // Determine the canonical position of the keyword const i = order.indexOf(x); @@ -633,4 +665,4 @@ class Registry { } -export { ShortcodeHandle, Registry }; +export { ShortcodeHandle, Registry, AcceptArbitraryArguments }; From 385503d6f0335c3a545d480d29797aafa97a3b8d Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Fri, 17 Oct 2025 15:27:57 +0200 Subject: [PATCH 14/26] WIP explicit arg getters --- .../js/tinymce-plugins/shortcodes/contact.js | 4 +- .../js/tinymce-plugins/shortcodes/utils.ts | 93 ++++++++++++++++++- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js index d1826dfe50..9bfe751af1 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js @@ -1,4 +1,4 @@ -import { ShortcodeHandle } from "./utils"; +import { ShortcodeHandle, AcceptArbitraryArguments } from "./utils"; class ContactHandle extends ShortcodeHandle { keyword = "contact"; @@ -7,7 +7,7 @@ class ContactHandle extends ShortcodeHandle { removeIcon = "remove"; pargs = [2, 8]; - kwargs = ["one", ["etc", false], "two", ["opt", false]]; + kwargs = ["one", ["etc", false, "The id "], "two", ["opt", false], AcceptArbitraryArguments]; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 0dff3f0dbc..70291de9e3 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -22,10 +22,10 @@ type ARBITRARY = typeof AcceptArbitraryArguments; type TextDescriptor = string | ((self: ShortcodeHandle) => string); -type SingleParg = null // Just to occupy this index. - | string // Name for the positional argument. - | [string, string]; // Name and description of the positional argument. -type PargsConstraint = ARBITRARY // Allow any number of positional arguments. +type SingleParg = null // Just to occupy this index. + | string // Name for the positional argument. + | [string, string?]; // Name and description of the positional argument. +type PargsDescriptor = ARBITRARY // Allow any number of positional arguments. | number // How many positional arguments have to be given (exactly, not more and not less). | [number, number | ARBITRARY] // How many positional arguments are required, and up to how many CAN be given (Infinity or null means unbounded). | [SingleParg[], SingleParg[] | [...SingleParg[], ARBITRARY] | ARBITRARY]; // The list of the required and the list of optional positional arguments. @@ -34,6 +34,11 @@ type SingleKWarg = string // Keyword (required) type KWargsDescriptor = ARBITRARY // Allow any keyword argument. | SingleKWarg[] // List of all keyword arguments being accepted. Also serves as a canonical order normalizing the shortcode. | [...SingleKWarg[], ARBITRARY]; // If the last element is AcceptArbitraryArguments, anything is accepted as optional argument. +type ExplicitArg = { + name: string; + required: boolean; + description: string; +}; class ShortcodeHandle { @@ -53,7 +58,7 @@ class ShortcodeHandle { return str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; } - pargs: PargsConstraint = AcceptArbitraryArguments; + pargs: PargsDescriptor = AcceptArbitraryArguments; kwargs: KWargsDescriptor = AcceptArbitraryArguments; get maxPargs(): number { if (this.pargs === AcceptArbitraryArguments) @@ -90,6 +95,49 @@ class ShortcodeHandle { return this.pargs.length; } } + getExplicitParg(index: number): ExplicitArg { + // Get an explicit descriptor of the positional argument at this index + if (index < 0 || index >= this.maxPargs) + throw RangeError; + const parg: ExplicitArg = { + name: null, + required: null, + description: null, + }; + if (this.pargs === AcceptArbitraryArguments) + parg.required = false; + else if (typeof this.pargs === "number") + parg.required = true; + else if (this.pargs.length > 0 && typeof this.pargs[0] === "number") { + let [min, max] = this.pargs; + parg.required = index < min; + } else { + // Listing all individual arguments style + let length = this.pargs.length; + if (this.pargs[length-1] === AcceptArbitraryArguments) + length -= 1; + if (index < length) { + if (typeof this.pargs[index] === "string") + parg.name = this.pargs[index]; + else if (this.pargs[index] !== null) { + const arg = this.pargs[index] as [string, string?]; + parg.name = arg[0]; + if (arg.length > 1) + parg.description = arg[1]; + } + } else + parg.required = false; + } + // Fill in generic name and description + if (parg.name === null) + parg.name = `Argument ${index}`; + if (parg.required === null) + parg.required = index < this.minPargs; + if (parg.description === null) + parg.description = ``; // Description stays empty + return parg; + } + get requiredKWargs(): Set { if (this.kwargs === AcceptArbitraryArguments) return new Set(); const known = this.kwargs.filter(kw => kw !== AcceptArbitraryArguments) as SingleKWarg[]; @@ -127,6 +175,41 @@ class ShortcodeHandle { const keywords = kwargs.map((kw: SingleKWarg) => typeof kw === "string" ? kw : kw[0]); return keywords; } + getExplicitKWarg(name: string): ExplicitArg { + // Get an explicit descriptor of the keyword argument at this index + if (!this.acceptingArbitraryKWargs && !this.requiredKWargs.has(name) && !this.optionalKWargs.has(name)) + throw RangeError; + + const kwarg: ExplicitArg = { + name: name, + required: null, + description: null, + }; + if (this.kwargs === AcceptArbitraryArguments) + kwarg.required = false; + else { + // Listing all individual arguments style + for (const descriptor of this.kwargs) { + if (typeof descriptor === "string") + if (descriptor == name) + break; // no description to add + else if (descriptor[0] == name) { + if (descriptor.length > 1) + kwarg.description = descriptor[1]; + break; + } + } + } + // Fill in generic name and description + if (kwarg.name === null) + kwarg.name = `${name}`; + if (kwarg.required === null) + kwarg.required = this.requiredKWargs.has(name); + if (kwarg.description === null) + kwarg.description = ``; // Description stays empty + return kwarg; + } + lastUnsavedPargs: string[] | null = null; lastUnsavedKWargs: [string, string][] | null = null; From 840bcf090189bea1901bfd1c89fbe5f611d8d1ef Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 20 Oct 2025 00:57:30 +0200 Subject: [PATCH 15/26] WIP display argument names and descriptions --- .../js/tinymce-plugins/shortcodes/contact.js | 14 -- .../js/tinymce-plugins/shortcodes/contact.ts | 17 +++ .../shortcodes/{page.js => page.ts} | 0 .../js/tinymce-plugins/shortcodes/utils.ts | 136 +++++++++++------- 4 files changed, 100 insertions(+), 67 deletions(-) delete mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js create mode 100644 integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts rename integreat_cms/static/src/js/tinymce-plugins/shortcodes/{page.js => page.ts} (100%) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js deleted file mode 100644 index 9bfe751af1..0000000000 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.js +++ /dev/null @@ -1,14 +0,0 @@ -import { ShortcodeHandle, AcceptArbitraryArguments } from "./utils"; - -class ContactHandle extends ShortcodeHandle { - keyword = "contact"; - addIcon = "contact"; - editIcon = "contact"; - removeIcon = "remove"; - - pargs = [2, 8]; - kwargs = ["one", ["etc", false, "The id "], "two", ["opt", false], AcceptArbitraryArguments]; -} - - -export default ContactHandle; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts new file mode 100644 index 0000000000..701bcab500 --- /dev/null +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts @@ -0,0 +1,17 @@ +import { ShortcodeHandle, AcceptArbitraryArguments, PargsDescriptor, KWargsDescriptor } from "./utils"; + +class ContactHandle extends ShortcodeHandle { + keyword = "contact"; + addIcon = "contact"; + editIcon = "contact"; + removeIcon = "remove"; + + pargs: PargsDescriptor = [ + ["first", ["second", "very descriptive"]], + [["third", "such wow"], "fourth"], + ]; + kwargs: KWargsDescriptor = ["one", ["etc", true, "The id ", "e.t.C."], "two", ["opt", false], AcceptArbitraryArguments]; +} + + +export default ContactHandle; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.js b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts similarity index 100% rename from integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.js rename to integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 70291de9e3..e3a32fe544 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -29,37 +29,40 @@ type PargsDescriptor = ARBITRARY | number // How many positional arguments have to be given (exactly, not more and not less). | [number, number | ARBITRARY] // How many positional arguments are required, and up to how many CAN be given (Infinity or null means unbounded). | [SingleParg[], SingleParg[] | [...SingleParg[], ARBITRARY] | ARBITRARY]; // The list of the required and the list of optional positional arguments. -type SingleKWarg = string // Keyword (required) - | [string, boolean?, string?]; // Keyword, whether it is required (default: not required) and a description of the argument. +type SingleKWarg = string // Keyword (required) + | [string, boolean?, string?, string?]; // Keyword, whether it is required (default: not required), a description of the argument and a human readable name to use instead of the default conversion of the keyword. type KWargsDescriptor = ARBITRARY // Allow any keyword argument. | SingleKWarg[] // List of all keyword arguments being accepted. Also serves as a canonical order normalizing the shortcode. | [...SingleKWarg[], ARBITRARY]; // If the last element is AcceptArbitraryArguments, anything is accepted as optional argument. type ExplicitArg = { name: string; + specifiedName?: string; + genericName?: string; required: boolean; description: string; + unifiedDescription: string; }; class ShortcodeHandle { - keyword: string; - endword: string | null = null; + readonly keyword: string; + readonly endword: string | null = null; editor: Editor; tinymceConfig: HTMLElement; - addText: TextDescriptor = (self: ShortcodeHandle) => `Insert ${self.keyword}`; - addIcon: string = "link"; - editText: TextDescriptor = (self: ShortcodeHandle) => `Edit ${self.keyword}`; - editIcon: string = "link"; - removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; - removeIcon: string = "unlink"; + readonly addText: TextDescriptor = (self: ShortcodeHandle) => `Insert ${self.keyword}`; + readonly addIcon: string = "link"; + readonly editText: TextDescriptor = (self: ShortcodeHandle) => `Edit ${self.keyword}`; + readonly editIcon: string = "link"; + readonly removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; + readonly removeIcon: string = "unlink"; static escape(str: string | undefined): string { if (!str) return '""'; return str.includes(" ") ? `"${str.replace(/"/g, '\\"')}"` : str; } - pargs: PargsDescriptor = AcceptArbitraryArguments; - kwargs: KWargsDescriptor = AcceptArbitraryArguments; + readonly pargs: PargsDescriptor = AcceptArbitraryArguments; + readonly kwargs: KWargsDescriptor = AcceptArbitraryArguments; get maxPargs(): number { if (this.pargs === AcceptArbitraryArguments) return Infinity; @@ -72,11 +75,13 @@ class ShortcodeHandle { else return this.pargs[1] as number; } else { - // Listing all individual arguments style - if (this.pargs.includes(AcceptArbitraryArguments)) + // Listing of required and list of optional positional arguments style + const required = this.pargs[0] as SingleParg[]; + const optional = this.pargs[1] as SingleParg[] | [...SingleParg[], ARBITRARY]; + if (optional[optional.length] === AcceptArbitraryArguments) return Infinity; else - return this.pargs.length; + return required.length + optional.length; } } get minPargs(): number { @@ -88,11 +93,8 @@ class ShortcodeHandle { // [min, max] style return this.pargs[0]; } else { - // Listing all individual arguments style - if (this.pargs.includes(AcceptArbitraryArguments)) - return Infinity; - else - return this.pargs.length; + // Listing of required and list of optional positional arguments style + return (this.pargs[0] as SingleParg[]).length; } } getExplicitParg(index: number): ExplicitArg { @@ -101,8 +103,11 @@ class ShortcodeHandle { throw RangeError; const parg: ExplicitArg = { name: null, + specifiedName: null, + genericName: `Argument ${index}`, required: null, description: null, + unifiedDescription: null, }; if (this.pargs === AcceptArbitraryArguments) parg.required = false; @@ -112,29 +117,40 @@ class ShortcodeHandle { let [min, max] = this.pargs; parg.required = index < min; } else { - // Listing all individual arguments style - let length = this.pargs.length; - if (this.pargs[length-1] === AcceptArbitraryArguments) - length -= 1; + // Listing of required and list of optional positional arguments style + const required = this.pargs[0] as SingleParg[]; + const optional = this.pargs[1] === AcceptArbitraryArguments ? [] : this.pargs[1] as SingleParg[] | [...SingleParg[], ARBITRARY]; + // Create a joined list that only contains argument descriptions, not the Symbol + const pargs = required.concat(( + optional[optional.length-1] === AcceptArbitraryArguments ? + optional.slice(0, optional.length-1) + : optional + ) as SingleParg[]); + let length = pargs.length; if (index < length) { - if (typeof this.pargs[index] === "string") - parg.name = this.pargs[index]; - else if (this.pargs[index] !== null) { - const arg = this.pargs[index] as [string, string?]; - parg.name = arg[0]; + if (typeof pargs[index] === "string") + parg.specifiedName = pargs[index]; + else if (pargs[index] !== null) { + const arg = pargs[index] as [string, string?]; + parg.specifiedName = arg[0]; if (arg.length > 1) parg.description = arg[1]; } } else parg.required = false; } - // Fill in generic name and description - if (parg.name === null) - parg.name = `Argument ${index}`; + // Fill in missing details + parg.name = parg.specifiedName !== null ? parg.specifiedName : parg.genericName; if (parg.required === null) parg.required = index < this.minPargs; if (parg.description === null) parg.description = ``; // Description stays empty + if (parg.specifiedName !== null) { + parg.unifiedDescription = parg.specifiedName; + if (parg.description) + parg.unifiedDescription += ` – ${parg.description}`; + } else + parg.unifiedDescription = parg.description || parg.genericName; return parg; } @@ -177,36 +193,48 @@ class ShortcodeHandle { } getExplicitKWarg(name: string): ExplicitArg { // Get an explicit descriptor of the keyword argument at this index - if (!this.acceptingArbitraryKWargs && !this.requiredKWargs.has(name) && !this.optionalKWargs.has(name)) - throw RangeError; + /*if (!this.acceptingArbitraryKWargs && !this.requiredKWargs.has(name) && !this.optionalKWargs.has(name)) + throw RangeError;*/ const kwarg: ExplicitArg = { - name: name, + name: null, + specifiedName: null, + genericName: `${name.slice(0,1).toUpperCase()}${name.slice(1).replace("-", " ")}`, required: null, description: null, + unifiedDescription: null, }; - if (this.kwargs === AcceptArbitraryArguments) + if (this.kwargs === AcceptArbitraryArguments) { kwarg.required = false; - else { + } else if (name) { // Listing all individual arguments style + // Iterate over them until we find the argument we are looking for for (const descriptor of this.kwargs) { - if (typeof descriptor === "string") - if (descriptor == name) - break; // no description to add - else if (descriptor[0] == name) { + if (typeof descriptor === "string") { + if (descriptor == name) { + kwarg.required = true; + break; + } + } else if (typeof descriptor === "object" && "length" in descriptor && descriptor[0] == name) { if (descriptor.length > 1) - kwarg.description = descriptor[1]; + kwarg.required = descriptor[1]; + if (descriptor.length > 2) + kwarg.description = descriptor[2]; + if (descriptor.length > 3) + kwarg.specifiedName = descriptor[3]; break; } } } - // Fill in generic name and description - if (kwarg.name === null) - kwarg.name = `${name}`; + // Fill in missing details + kwarg.name = kwarg.specifiedName !== null ? kwarg.specifiedName : kwarg.genericName; if (kwarg.required === null) kwarg.required = this.requiredKWargs.has(name); if (kwarg.description === null) kwarg.description = ``; // Description stays empty + kwarg.unifiedDescription = kwarg.name; + if (kwarg.description) + kwarg.unifiedDescription += ` – ${kwarg.description}`; return kwarg; } @@ -405,10 +433,11 @@ class ShortcodeHandle { // The default implementation for constructing the edit dialog for a generic shortcode const argumentItems: BodyComponentSpec[] = []; initialPargs.forEach((parg: string, i: number) => { + const explicit = this.getExplicitParg(i); argumentItems.push({ type: "input", name: `parg${i}`, - label: `Argument ${i}`, + label: explicit.unifiedDescription, }); }); if (this.minPargs != this.maxPargs) { @@ -427,6 +456,7 @@ class ShortcodeHandle { }); } initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { + const explicit = this.getExplicitKWarg(keyword); if (this.acceptingArbitraryKWargs) { argumentItems.push({ type: "bar", @@ -434,12 +464,12 @@ class ShortcodeHandle { { type: "input", name: `kwarg${i}-name`, - label: `Keyword argument ${i}`, + label: explicit.name, }, { type: "input", name: `kwarg${i}-value`, - label: `Value`, + label: explicit.description || `Value`, }, ], }); @@ -447,7 +477,7 @@ class ShortcodeHandle { argumentItems.push({ type: "input", name: `kw-${keyword}`, - label: `${keyword.slice(0,1).toUpperCase()}${keyword.slice(1).replace("-", " ")}`, + label: explicit.unifiedDescription, }); } }); @@ -498,7 +528,7 @@ class ShortcodeHandle { initialData: { ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { - if (this.kwargs === AcceptArbitraryArguments) { + if (this.acceptingArbitraryKWargs) { return acc.concat([ [`kwarg${i}-name`, keyword], [`kwarg${i}-value`, value], @@ -607,9 +637,9 @@ class ShortcodeHandle { if (missingOptional.size > 0) { // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty key = Array.from(missingOptional).sort((a, b) => index(a) - index(b))[0] || ""; - } else if (this.acceptingArbitraryKWargs) + } else if (this.acceptingArbitraryKWargs) { key = ""; - else + } else return; // There are no arguments left to add, immediately stop without doing anything } // Finally, actually append the key value pair and retrigger the dialog @@ -748,4 +778,4 @@ class Registry { } -export { ShortcodeHandle, Registry, AcceptArbitraryArguments }; +export { ShortcodeHandle, Registry, AcceptArbitraryArguments, TextDescriptor, SingleParg, PargsDescriptor, SingleKWarg, KWargsDescriptor, ExplicitArg }; From 6f3ee35c8294ec0b223fdb60eb4af6bc0b7ac3b7 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Wed, 22 Oct 2025 12:48:30 +0200 Subject: [PATCH 16/26] WIP uh I forgot --- integreat_cms/static/src/css/style.scss | 1 + ....css => tinymce_custom_content_styles.css} | 0 .../static/src/css/tinymce_ui_overrides.scss | 8 ++ integreat_cms/static/src/editor_content.ts | 2 +- .../js/tinymce-plugins/shortcodes/contact.ts | 12 ++- .../src/js/tinymce-plugins/shortcodes/page.ts | 17 ++-- .../js/tinymce-plugins/shortcodes/utils.ts | 85 ++++++++++++++----- 7 files changed, 91 insertions(+), 34 deletions(-) rename integreat_cms/static/src/css/{tinymce_custom.css => tinymce_custom_content_styles.css} (100%) create mode 100644 integreat_cms/static/src/css/tinymce_ui_overrides.scss diff --git a/integreat_cms/static/src/css/style.scss b/integreat_cms/static/src/css/style.scss index 95469d1f7d..fefad7b3a4 100644 --- a/integreat_cms/static/src/css/style.scss +++ b/integreat_cms/static/src/css/style.scss @@ -29,6 +29,7 @@ $fp-4x3-path: "flagpack-dart-sass/flags/4x3/"; } @import "./upload_form.css"; +@import "./tinymce_ui_overrides"; @import "./tomselect.scss"; $list-bg-color: white; diff --git a/integreat_cms/static/src/css/tinymce_custom.css b/integreat_cms/static/src/css/tinymce_custom_content_styles.css similarity index 100% rename from integreat_cms/static/src/css/tinymce_custom.css rename to integreat_cms/static/src/css/tinymce_custom_content_styles.css diff --git a/integreat_cms/static/src/css/tinymce_ui_overrides.scss b/integreat_cms/static/src/css/tinymce_ui_overrides.scss new file mode 100644 index 0000000000..7c04c005f8 --- /dev/null +++ b/integreat_cms/static/src/css/tinymce_ui_overrides.scss @@ -0,0 +1,8 @@ +.tox label.tox-label { + /* .tox label.tox-label + * is a more specific selector than + * .tox .tox-label + * and thus takes precedence over the default + */ + white-space: wrap; +} diff --git a/integreat_cms/static/src/editor_content.ts b/integreat_cms/static/src/editor_content.ts index db6d286096..98f0f49865 100644 --- a/integreat_cms/static/src/editor_content.ts +++ b/integreat_cms/static/src/editor_content.ts @@ -11,4 +11,4 @@ import "./js/tinymce-plugins/mediacenter/plugin.js"; import "./js/tinymce-plugins/shortcodes/plugin.js"; /* Custom tinymce content css */ -import "./css/tinymce_custom.css"; +import "./css/tinymce_custom_content_styles.css"; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts index 701bcab500..63384b05f3 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts @@ -7,10 +7,16 @@ class ContactHandle extends ShortcodeHandle { removeIcon = "remove"; pargs: PargsDescriptor = [ - ["first", ["second", "very descriptive"]], - [["third", "such wow"], "fourth"], + [["Contact ID", "The ID of the Contact whose details should be displayed"]], + [ + ["address", "Whether the address should be shown and other, not explicitly wanted details should be hidden"], + ["email", "Whether the email should be shown and other, not explicitly wanted details should be hidden"], + ["phone_number", "Whether the phone number should be shown and other, not explicitly wanted details should be hidden"], + ["mobile_phone_number", "Whether the mobile phone number should be shown and other, not explicitly wanted details should be hidden"], + ["website", "Whether the website should be shown and other, not explicitly wanted details should be hidden"], + ], ]; - kwargs: KWargsDescriptor = ["one", ["etc", true, "The id ", "e.t.C."], "two", ["opt", false], AcceptArbitraryArguments]; + kwargs: KWargsDescriptor = []; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 072cae9ba5..0d9172340c 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -1,18 +1,13 @@ -import { ShortcodeHandle } from "./utils"; - -/* - -- canonical representation (normalized shortcode) -- rendered preview -- dialog system to edit - -*/ - +import { ShortcodeHandle, AcceptArbitraryArguments, PargsDescriptor, KWargsDescriptor } from "./utils"; class PageHandle extends ShortcodeHandle { keyword = "page"; - t() {} + pargs: PargsDescriptor = [ + [["id", "The ID of the page to link to"]], + [["text", "The text to display (if not specified, show page title)"]], + ]; + kwargs: KWargsDescriptor = []; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index e3a32fe544..371205112a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -434,11 +434,29 @@ class ShortcodeHandle { const argumentItems: BodyComponentSpec[] = []; initialPargs.forEach((parg: string, i: number) => { const explicit = this.getExplicitParg(i); - argumentItems.push({ - type: "input", - name: `parg${i}`, - label: explicit.unifiedDescription, - }); + argumentItems.push.apply(argumentItems, [ + { + type: "label", + label: explicit.unifiedDescription, + for: `parg${i}`, + items: [ + { + type: "bar", + items: [ + { + type: "input", + name: `parg${i}`, + //label: explicit.unifiedDescription, + }, + ...(explicit.required ? [] : [{ + type: "htmlpanel", + html: ``, + }] as BodyComponentSpec[]), + ], + }, + ], + }, + ]); }); if (this.minPargs != this.maxPargs) { argumentItems.push({ @@ -458,26 +476,55 @@ class ShortcodeHandle { initialKWargs.forEach(([keyword, value]: [string, string], i: number) => { const explicit = this.getExplicitKWarg(keyword); if (this.acceptingArbitraryKWargs) { - argumentItems.push({ - type: "bar", + argumentItems.push.apply(argumentItems, [ + { + type: "label", + label: explicit.description || `Value`, + for: `parg${i}`, items: [ { - type: "input", - name: `kwarg${i}-name`, - label: explicit.name, - }, - { - type: "input", - name: `kwarg${i}-value`, - label: explicit.description || `Value`, + type: "bar", + items: [ + { + type: "input", + name: `kwarg${i}-name`, + label: explicit.name, + }, + { + type: "input", + name: `kwarg${i}-value`, + //label: explicit.description || `Value`, + }, + ...(explicit.required ? [] : [{ + type: "htmlpanel", + html: ``, + }] as BodyComponentSpec[]), + ], }, ], - }); + }, + ]); } else { argumentItems.push({ - type: "input", - name: `kw-${keyword}`, - label: explicit.unifiedDescription, + type: "label", + label: explicit.description || `Value`, + for: `parg${i}`, + items: [ + { + type: "bar", + items: [ + { + type: "input", + name: `kw-${keyword}`, + label: explicit.unifiedDescription, + }, + ...(explicit.required ? [] : [{ + type: "htmlpanel", + html: ``, + }] as BodyComponentSpec[]), + ], + }, + ], }); } }); From f83ef1a75fb9bb9d0389e313940660a4eb5fe58c Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Fri, 6 Feb 2026 19:36:48 +0100 Subject: [PATCH 17/26] WIP: add page shortcode UI --- integreat_cms/api/v3/pages.py | 1 + integreat_cms/cms/models/utils.py | 2 + .../cms/templates/_tinymce_config.html | 3 + .../src/js/tinymce-plugins/shortcodes/page.ts | 420 ++++++++++++++++++ .../js/tinymce-plugins/shortcodes/utils.ts | 250 ++++++----- 5 files changed, 558 insertions(+), 118 deletions(-) diff --git a/integreat_cms/api/v3/pages.py b/integreat_cms/api/v3/pages.py index 7fa803bb5e..fd2318f6e9 100644 --- a/integreat_cms/api/v3/pages.py +++ b/integreat_cms/api/v3/pages.py @@ -90,6 +90,7 @@ def transform_page( expand_shortcodes(page_translation.combined_text, context=context) ), "content": expand_shortcodes(page_translation.combined_text, context=context), + "page_id": page_translation.page.id, "parent": parent, "order": order, "available_languages": page_translation.available_languages_dict, diff --git a/integreat_cms/cms/models/utils.py b/integreat_cms/cms/models/utils.py index c660f9e26e..ab8fee6b32 100644 --- a/integreat_cms/cms/models/utils.py +++ b/integreat_cms/cms/models/utils.py @@ -50,6 +50,8 @@ def format_object_translation( + object_translation.link_title.tail ) return { + "id": object_translation.id, + "foreign_object_id": object_translation.foreign_object.id, "path": object_translation.path(), "title": text_title, "html_title": html_title, diff --git a/integreat_cms/cms/templates/_tinymce_config.html b/integreat_cms/cms/templates/_tinymce_config.html index 6eccad192a..8c5e581522 100644 --- a/integreat_cms/cms/templates/_tinymce_config.html +++ b/integreat_cms/cms/templates/_tinymce_config.html @@ -19,8 +19,11 @@ {% comment %} Styling for text diff taken from style.scss {% endcomment %} {% firstof font_style|add:"del { background-color: rgb(252 165 165); } ins { background-color: rgb(134 239 172); text-decoration-line: none; }" as content_style %}
}; + +function stripProtocol(url: string) { + return url.replace(/^[^:/]*:\/\//, ""); +} + +function evaluateOnceDecorator(fn: ()=>T): ()=>T { + let value: T | null = null; + // Save whether we computed the value as a separate boolean, in case we ever literally compute null + let computed = false; + return (): T => { + if (!computed) { + value = fn(); + computed = true; + } + return value; + }; +}; + class PageHandle extends ShortcodeHandle { keyword = "page"; @@ -8,7 +31,404 @@ class PageHandle extends ShortcodeHandle { [["text", "The text to display (if not specified, show page title)"]], ]; kwargs: KWargsDescriptor = []; + + editText = ""; + + domainAndPrefix: () => string = evaluateOnceDecorator(() => stripProtocol(this.tinymceConfig.getAttribute("data-webapp-url"))); + // Regular expression to check íf a link could be a page + // Capture groups: Path, Region slug, language slug, page infix, page slug + internalPageURLRegex: () => RegExp = evaluateOnceDecorator(() => new RegExp(String.raw` + ^[^:/]*:// + ${this.domainAndPrefix().replace(/\/$/, "")} + ( + / + ([^/]+) + / + ([^/]{2,8}) + / + ( + ([^?#]+) + / + )? + ([^/?#]+) + ) + `.replace(/\s+/g, ""))); + + pageCache = new PageCache(); + + predicate(node: Element): boolean { + // We also consider old links that look like they point to pages as instances of the page shortcode. + // This way shortcodes will work on the old links and we will naturally slowly convert content from the old style direct links. + if (node.nodeName.toLowerCase() === "a") { + const href = (node as HTMLLinkElement).href; + if (href && (node as HTMLElement).isContentEditable && this.internalPageURLRegex().exec(href)) { + return true; + } + } + return super.predicate(node); + } + + argsFromNode(node: HTMLElement | null): [string[], Map] { + // If we are operating on an old style direct link, we need to recover what that would be as the arguments for the new shortcode. + if (node && node.nodeName.toLowerCase() === "a") { + const [fullURL, path, regionSlug, languageSlug, infix, pageSlug] = node.getAttribute("href").match(this.internalPageURLRegex()); + let text = node.textContent !== node.getAttribute("href") ? node.textContent : ""; + // Get page id for slug + const translation = this.pageCache.bySlug.get(languageSlug).get(path); + const id = translation.id; + return [[`${id}`, text], new Map()]; + } + return super.argsFromNode(node); + } + + renderPreviewNode(pargs: string[], kwargs: Map): string { + // The html string representation of the shortcode in the TinyMCE editor + this.prefetchPageById(parseInt(pargs[0])); + return super.renderPreviewNode(pargs, kwargs) + } + + async getCompletions(query: string, id: number) { + const url = this.tinymceConfig.getAttribute("data-link-ajax-url"); + const response = await fetch(url, { + method: "POST", + headers: { + "X-CSRFToken": getCsrfToken(), + }, + body: JSON.stringify({ + query_string: query, + object_types: ["event", "page", "poi"], + archived: false, + is_link_suggestion: true, + }), + }); + const HTTP_STATUS_OK = 200; + if (response.status !== HTTP_STATUS_OK) { + return []; + } + + const data = await response.json(); + return [data.data, id]; + } + + displayEditDialog(initialPargs: string[], initialKWargs: [string, string][]) { + const ID_ARG = "parg0"; + const TEXT_ARG = "parg1"; + + const node = this.getNode(); + const initialText = node ? initialPargs[1] : this.editor.selection.getContent({ format: "text" }); + + let prevSearchText = ""; + let prevSelectedCompletion = initialPargs[0] ? initialPargs[0] : ""; + + // Stores the current request id, so that outdated requests get ignored + let ajaxRequestId = 0; + const defaultCompletionItem = { + text: this.tinymceConfig.getAttribute("data-link-no-results-text"), + title: "", + value: "", + }; + const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); + const cachedPageData = this.pageCache.byId.get(parseInt(initialPargs[0])).get(languageSlug); + const initialCompletionItem = { + text: cachedPageData.path, + title: cachedPageData.title, + value: `${initialPargs[0]}`, + }; + const completionItems = cachedPageData ? [initialCompletionItem] : [defaultCompletionItem]; + let currentCompletionText = ""; + + const that = this; + const updateDialog = (api: DialogInstanceApi) => { + super.defaultOnChange(api); + + let data = api.getData(); + + let urlChangedBySearch = false; + // Check if the selected completion changed + if (prevSelectedCompletion !== data[ID_ARG]) { + // find the correct text currently shown in the completion items box + if (completionItems.length > 0) { + const currentCompletion = completionItems.find( + (completion) => completion.value === data[ID_ARG] + ); + // Don't set the completion text to `- no results -` + if (currentCompletion.value !== "") { + currentCompletionText = currentCompletion.title; + } else { + currentCompletionText = ""; + } + } else { + currentCompletionText = ""; + } + } + prevSelectedCompletion = data[ID_ARG]; + + // Disable the submit button if no valid page found + api.setEnabled("submit", data[ID_ARG]); + + // make new ajax request on user input + if (data.search !== prevSearchText && data.search !== "") { + ajaxRequestId += 1; + this.getCompletions(data.search, ajaxRequestId).then(([newCompletions, requestId]) => { + if (requestId !== ajaxRequestId) { + return; + } + + completionItems.length = 0; + for (const completion of newCompletions) { + const [fullURL, path, regionSlug, languageSlug, infix, pageSlug] = completion.url.match(that.internalPageURLRegex()); + completionItems.push({ + text: completion.path, + title: completion.html_title, + value: `${completion.foreign_object_id}`, + //value: `${that.pageCache.bySlug.get(languageSlug).get(path).pageId}`, + }); + } + + let completionDisabled = false; + if (completionItems.length === 0) { + completionDisabled = true; + completionItems.push(defaultCompletionItem); + } + + + // It seems like there is no better way to update the completion list + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + api.redial(dialogConfig); + api.setData(data); + api.focus("search"); + prevSearchText = data.search; + + api.setEnabled(ID_ARG, !completionDisabled); + + updateDialog(api); + }); + } else if (data.search === "" && prevSearchText !== "") { + // force an update so that the original user url can get restored + completionItems.length = 0; + completionItems.push(defaultCompletionItem); + /* eslint-disable-next-line @typescript-eslint/no-use-before-define */ + api.redial(dialogConfig); + api.setData(data); + api.focus("search"); + prevSearchText = data.search; + //api.disable(ID_ARG); + updateDialog(api); + } + }; + + const completion: any = {}; + completion[ID_ARG] = prevSelectedCompletion + const dialogConfig: DialogSpec = { + title: this.text(this.editText), + body: { + type: "panel", + items: [ + { + type: "input", + name: TEXT_ARG, + label: this.tinymceConfig.getAttribute("data-link-dialog-text-text"), + //disabled: textDisabled, + }, + { + type: "label", + label: this.tinymceConfig.getAttribute("data-link-dialog-internal_link-text"), + items: [ + { + type: "input", + name: "search", + }, + { + type: "selectbox", + name: ID_ARG, + items: completionItems, + //disabled: true, + }, + ], + }, + ], + }, + buttons: [ + { + type: "cancel", + text: this.tinymceConfig.getAttribute("data-dialog-cancel-text"), + }, + { + type: "submit", + name: "submit", + text: this.tinymceConfig.getAttribute("data-dialog-submit-text"), + primary: true, + enabled: false, + }, + ], + initialData: { + ...this.defaultInitialData(initialPargs, initialKWargs), + ...completion, + }, + onSubmit: this.defaultOnSubmit.bind(this), + onChange: updateDialog.bind(this), + }; + + return this.editor.windowManager.open(dialogConfig); + } + + prefetchPageById(id: number) { + if (!id) return; + + // todo: prevent duplicates + + const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); + const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); + const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); + const url = `${baseUrl}/api/v3/${regionSlug}/${languageSlug}/page/?id=${id}`; + + fetch(url, { + method: "GET", + headers: { + "X-CSRFToken": getCsrfToken(), + }, + }).then((response): any => { + const HTTP_STATUS_OK = 200; + if (response.status !== HTTP_STATUS_OK) { + return {}; + } + return response.json(); + }).then(translation => { + this.pageCache.cacheTranslationMetadata(languageSlug, translation, translation.page_id); + Object.entries(translation.available_languages).forEach(([lang, tr]) => { + this.pageCache.cacheTranslationMetadata(lang, tr, translation.page_id); + }); + console.log(this.pageCache); + }); + } + + populatePageCache() { + const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); + const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); + const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); + const url = `${baseUrl}/api/v3/${regionSlug}/${languageSlug}/pages/`; + + fetch(url, { + method: "GET", + headers: { + "X-CSRFToken": getCsrfToken(), + }, + }).then((response): any => { + const HTTP_STATUS_OK = 200; + if (response.status !== HTTP_STATUS_OK) { + return []; + } + return response.json(); + }).then(data => { + data.forEach((translation: any) => { + this.pageCache.cacheTranslationMetadata(languageSlug, translation, translation.page_id); + Object.entries(translation.available_languages).forEach(([lang, tr]) => { + this.pageCache.cacheTranslationMetadata(lang, tr, translation.page_id); + }); + }); + }); + } + + setup(editor: Editor) { + super.setup(editor); + //this.populatePageCache(); + + this.editText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); + } } +class PageCache { + bySlug = new Map(); + byId = new Map(); + + pending = new Map(); + + baseUrl: string; + regionSlug: string; + defaultLanguageSlug: string; + + _pathRegex = new RegExp(String.raw` + / + ([^/]+) + / + ([^/]{2,8}) + / + ( + ([^?#]+) + / + )? + ([^/?#]+) + `.replace(/\s+/g, ""))); + + constructor(baseUrl: string, regionSlug: string, defaultLanguageSlug: string) { + this.baseUrl = baseUrl; + this.regionSlug = regionSlug; + this.defaultLanguageSlug = defaultLanguageSlug; + } + + cacheTranslationMetadata(translation: any) { + pageId = pageId || translation.page_id; + const [regionSlug, languageSlug, infix, _, slug] = translation.path.match(that._pathRegex); + + const metadata = this.byId.get(pageId) || { + id: pageId, + parent: translation.parent.id, + regionSlug: regionSlug, + translations: new Map { + this.cacheTranslationMetadata(lang, tr, translation.page_id); + }); + return metadata; + } + + if (this.byId.has(id)) { + // Return a Promise that immediately resolves + return new Promise((res, rej) => res(this.byId.get(id))); + } else if (!this.pending.has(id)) { + // Start a new query + this.pending.set(id, inner(id, languageSlug)); + } + // Return the Promise of the ongoing query + return this.pending.get(id); + } +} + export default PageHandle; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 371205112a..8efd39244a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -572,132 +572,146 @@ class ShortcodeHandle { primary: true, }, ], - initialData: { - ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), - ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { - if (this.acceptingArbitraryKWargs) { - return acc.concat([ - [`kwarg${i}-name`, keyword], - [`kwarg${i}-value`, value], - ]); - } else { - return acc.concat([ - [`kw-${keyword}`, value], - ]); - } - }, [])), - }, - onSubmit: (api: DialogInstanceApi) => { - const [pargs, kwargs, orderedKWargs] = this.reconstructArgsFromDialog(api); - this.lastUnsavedPargs = pargs; - this.lastUnsavedKWargs = orderedKWargs; + initialData: this.defaultInitialData(initialPargs, initialKWargs), + onSubmit: this.defaultOnSubmit.bind(this), + onChange: this.defaultOnChange.bind(this), + }; + console.log(`[${this.keyword}]`, this, dialogConfig); - // Don't close the dialog if the arguments are not valid - if (!this.validate(pargs, kwargs)) - return; + setTimeout(this.defaultEditDialogRefinement, 0); - api.close(); - this.lastUnsavedPargs = null; - this.lastUnsavedKWargs = null; + return this.editor.windowManager.open(dialogConfig); + } - // Either insert a new shortcode or update the existing one - const node = this.getNode(); - if (!node) { - this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + defaultInitialData(initialPargs: string[], initialKWargs: [string, string][]) { + return { + ...Object.fromEntries(initialPargs.map((parg: string, i: number) => [`parg${i}`, parg])), + ...Object.fromEntries(initialKWargs.reduce((acc, [keyword, value], i) => { + if (this.acceptingArbitraryKWargs) { + return acc.concat([ + [`kwarg${i}-name`, keyword], + [`kwarg${i}-value`, value], + ]); } else { - node.remove(); - this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + return acc.concat([ + [`kw-${keyword}`, value], + ]); } - }, - onChange: (api: DialogInstanceApi) => { - const [pargs, kwargs, orderedKWargs] = this.reconstructArgsFromDialog(api); - this.lastUnsavedPargs = pargs; - this.lastUnsavedKWargs = orderedKWargs; - }, + }, [])), }; - console.log(`[${this.keyword}]`, this, dialogConfig); + } - setTimeout(() => { - const dialog = document.querySelector('.tox-dialog'); - const pargRemove = dialog.querySelector('#parg-remove'); - const pargAdd = dialog.querySelector('#parg-add'); - if (pargRemove) { - pargRemove.addEventListener("click", (() => { - if (this.lastUnsavedPargs === null || this.lastUnsavedPargs.length <= this.minPargs) - return; - this.lastUnsavedPargs.pop(); - this.editor.windowManager.close(); - this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); - }).bind(this)); - } - if (pargAdd) { - pargAdd.addEventListener("click", (() => { - if (this.lastUnsavedPargs === null || this.lastUnsavedPargs.length >= this.maxPargs) - return; - this.lastUnsavedPargs.push(""); - this.editor.windowManager.close(); - this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); - }).bind(this)); - } - const kwargRemove = dialog.querySelector('#kwarg-remove'); - const kwargAdd = dialog.querySelector('#kwarg-add'); - if (kwargRemove) { - kwargRemove.addEventListener("click", (() => { - if (this.lastUnsavedKWargs === null) - return; - const requiredKWargs = this.requiredKWargs; - // Throw away the last keyword that is not required - for (let i = this.lastUnsavedKWargs.length-1; i >= 0; i--) { - if (requiredKWargs.has(this.lastUnsavedKWargs[i][0])) - continue; - const beforeThis = this.lastUnsavedKWargs.slice(0, i); - const afterThis = this.lastUnsavedKWargs.slice(i+1, this.lastUnsavedKWargs.length); - this.lastUnsavedKWargs = beforeThis.concat(afterThis); - break; - }; - this.editor.windowManager.close(); - this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); - }).bind(this)); - } - if (kwargAdd) { - kwargAdd.addEventListener("click", (() => { - if (this.lastUnsavedKWargs === null) - return; - // A flat list of known keywords in canonical order - const order = this.kwargsOrder; - function index(x: string): number { - // Determine the canonical position of the keyword - const i = order.indexOf(x); - if (i == -1) return Infinity; // If the keyword is unknown, sort it last - return i; - } - const requiredKWargs = this.requiredKWargs; - const keys = new Set(this.lastUnsavedKWargs.map(([key, value]) => key)); - const missingRequired = requiredKWargs.difference(keys); - let key; - if (missingRequired.size > 0) { - // Somehow, required keywords are missing. Add the first one by canonical order - key = Array.from(missingRequired).sort((a, b) => index(a) - index(b))[0]; - } else { - const optionalKWargs = this.optionalKWargs; - const missingOptional = optionalKWargs.difference(keys); - if (missingOptional.size > 0) { - // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty - key = Array.from(missingOptional).sort((a, b) => index(a) - index(b))[0] || ""; - } else if (this.acceptingArbitraryKWargs) { - key = ""; - } else - return; // There are no arguments left to add, immediately stop without doing anything - } - // Finally, actually append the key value pair and retrigger the dialog - this.lastUnsavedKWargs.push([key, ""]); - this.editor.windowManager.close(); - this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); - }).bind(this)); - } - }, 0); + defaultOnSubmit(api: DialogInstanceApi) { + // The default submit handler for the edit dialog + const [pargs, kwargs, orderedKWargs] = this.reconstructArgsFromDialog(api); + this.lastUnsavedPargs = pargs; + this.lastUnsavedKWargs = orderedKWargs; - return this.editor.windowManager.open(dialogConfig); + // Don't close the dialog if the arguments are not valid + if (!this.validate(pargs, kwargs)) + return console.error(`invalid arguments:`, pargs, kwargs); + + api.close(); + this.lastUnsavedPargs = null; + this.lastUnsavedKWargs = null; + + // Either insert a new shortcode or update the existing one + const node = this.getNode(); + if (!node) { + this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + } else { + node.remove(); + this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + } + } + + defaultOnChange(api: DialogInstanceApi) { + // The default change handler for the edit dialog + const [pargs, kwargs, orderedKWargs] = this.reconstructArgsFromDialog(api); + this.lastUnsavedPargs = pargs; + this.lastUnsavedKWargs = orderedKWargs; + } + + defaultEditDialogRefinement() { + // The default function invoked after rendering the edit dialog, + // e.g. to manipulate its HTML in a way TinyMCEs DialogSpec doesn't support + const dialog = document.querySelector('.tox-dialog'); + const pargRemove = dialog.querySelector('#parg-remove'); + const pargAdd = dialog.querySelector('#parg-add'); + if (pargRemove) { + pargRemove.addEventListener("click", (() => { + if (this.lastUnsavedPargs === null || this.lastUnsavedPargs.length <= this.minPargs) + return; + this.lastUnsavedPargs.pop(); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + if (pargAdd) { + pargAdd.addEventListener("click", (() => { + if (this.lastUnsavedPargs === null || this.lastUnsavedPargs.length >= this.maxPargs) + return; + this.lastUnsavedPargs.push(""); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + const kwargRemove = dialog.querySelector('#kwarg-remove'); + const kwargAdd = dialog.querySelector('#kwarg-add'); + if (kwargRemove) { + kwargRemove.addEventListener("click", (() => { + if (this.lastUnsavedKWargs === null) + return; + const requiredKWargs = this.requiredKWargs; + // Throw away the last keyword that is not required + for (let i = this.lastUnsavedKWargs.length-1; i >= 0; i--) { + if (requiredKWargs.has(this.lastUnsavedKWargs[i][0])) + continue; + const beforeThis = this.lastUnsavedKWargs.slice(0, i); + const afterThis = this.lastUnsavedKWargs.slice(i+1, this.lastUnsavedKWargs.length); + this.lastUnsavedKWargs = beforeThis.concat(afterThis); + break; + }; + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } + if (kwargAdd) { + kwargAdd.addEventListener("click", (() => { + if (this.lastUnsavedKWargs === null) + return; + // A flat list of known keywords in canonical order + const order = this.kwargsOrder; + function index(x: string): number { + // Determine the canonical position of the keyword + const i = order.indexOf(x); + if (i == -1) return Infinity; // If the keyword is unknown, sort it last + return i; + } + const requiredKWargs = this.requiredKWargs; + const keys = new Set(this.lastUnsavedKWargs.map(([key, value]) => key)); + const missingRequired = requiredKWargs.difference(keys); + let key; + if (missingRequired.size > 0) { + // Somehow, required keywords are missing. Add the first one by canonical order + key = Array.from(missingRequired).sort((a, b) => index(a) - index(b))[0]; + } else { + const optionalKWargs = this.optionalKWargs; + const missingOptional = optionalKWargs.difference(keys); + if (missingOptional.size > 0) { + // Add the first known optional argument by canonical order. If we don't know any and we accept arbitrary arguments, leave the key empty + key = Array.from(missingOptional).sort((a, b) => index(a) - index(b))[0] || ""; + } else if (this.acceptingArbitraryKWargs) { + key = ""; + } else + return; // There are no arguments left to add, immediately stop without doing anything + } + // Finally, actually append the key value pair and retrigger the dialog + this.lastUnsavedKWargs.push([key, ""]); + this.editor.windowManager.close(); + this.displayEditDialog(this.lastUnsavedPargs, this.lastUnsavedKWargs); + }).bind(this)); + } } setup(editor: Editor) { From b42e1524d1237a95ad36988625009b6fc171e4d5 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 9 Feb 2026 00:14:36 +0100 Subject: [PATCH 18/26] WIP finished proper page data cache --- .../src/js/tinymce-plugins/shortcodes/page.ts | 215 +++++++++++------- .../js/tinymce-plugins/shortcodes/utils.ts | 2 +- 2 files changed, 137 insertions(+), 80 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index d3ef826dac..e4fc614b31 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -3,8 +3,6 @@ import type { ToolbarButtonInstanceApi, ContextFormInstanceApi, ContextFormButto import { Editor } from "tinymce"; import { getCsrfToken } from "../../utils/csrf-token"; -type TranslationMetadata = {id: number, parent: number, regionSlug: string, translations: Map}; - function stripProtocol(url: string) { return url.replace(/^[^:/]*:\/\//, ""); } @@ -54,7 +52,7 @@ class PageHandle extends ShortcodeHandle { ) `.replace(/\s+/g, ""))); - pageCache = new PageCache(); + pageCache: PageCache = null; predicate(node: Element): boolean { // We also consider old links that look like they point to pages as instances of the page shortcode. @@ -74,8 +72,8 @@ class PageHandle extends ShortcodeHandle { const [fullURL, path, regionSlug, languageSlug, infix, pageSlug] = node.getAttribute("href").match(this.internalPageURLRegex()); let text = node.textContent !== node.getAttribute("href") ? node.textContent : ""; // Get page id for slug - const translation = this.pageCache.bySlug.get(languageSlug).get(path); - const id = translation.id; + const translation = this.pageCache.byPath.get(path); + const id = translation.page.id; return [[`${id}`, text], new Map()]; } return super.argsFromNode(node); @@ -83,7 +81,7 @@ class PageHandle extends ShortcodeHandle { renderPreviewNode(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode in the TinyMCE editor - this.prefetchPageById(parseInt(pargs[0])); + this.pageCache.requestId(parseInt(pargs[0]), true).then(); return super.renderPreviewNode(pargs, kwargs) } @@ -128,9 +126,9 @@ class PageHandle extends ShortcodeHandle { value: "", }; const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); - const cachedPageData = this.pageCache.byId.get(parseInt(initialPargs[0])).get(languageSlug); + const cachedPageData = this.pageCache.byId.get(parseInt(initialPargs[0])).translations.get(languageSlug); const initialCompletionItem = { - text: cachedPageData.path, + text: cachedPageData.titlePath, title: cachedPageData.title, value: `${initialPargs[0]}`, }; @@ -181,7 +179,7 @@ class PageHandle extends ShortcodeHandle { text: completion.path, title: completion.html_title, value: `${completion.foreign_object_id}`, - //value: `${that.pageCache.bySlug.get(languageSlug).get(path).pageId}`, + //value: `${that.pageCache.bySlug.get(languageSlug).get(path).page.id}`, }); } @@ -272,36 +270,6 @@ class PageHandle extends ShortcodeHandle { return this.editor.windowManager.open(dialogConfig); } - prefetchPageById(id: number) { - if (!id) return; - - // todo: prevent duplicates - - const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); - const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); - const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); - const url = `${baseUrl}/api/v3/${regionSlug}/${languageSlug}/page/?id=${id}`; - - fetch(url, { - method: "GET", - headers: { - "X-CSRFToken": getCsrfToken(), - }, - }).then((response): any => { - const HTTP_STATUS_OK = 200; - if (response.status !== HTTP_STATUS_OK) { - return {}; - } - return response.json(); - }).then(translation => { - this.pageCache.cacheTranslationMetadata(languageSlug, translation, translation.page_id); - Object.entries(translation.available_languages).forEach(([lang, tr]) => { - this.pageCache.cacheTranslationMetadata(lang, tr, translation.page_id); - }); - console.log(this.pageCache); - }); - } - populatePageCache() { const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); @@ -321,9 +289,9 @@ class PageHandle extends ShortcodeHandle { return response.json(); }).then(data => { data.forEach((translation: any) => { - this.pageCache.cacheTranslationMetadata(languageSlug, translation, translation.page_id); + this.pageCache.cacheTranslationMetadata(translation); Object.entries(translation.available_languages).forEach(([lang, tr]) => { - this.pageCache.cacheTranslationMetadata(lang, tr, translation.page_id); + this.pageCache.cacheTranslationMetadata(tr); }); }); }); @@ -334,15 +302,20 @@ class PageHandle extends ShortcodeHandle { //this.populatePageCache(); this.editText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); + + const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); + const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); + const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); + this.pageCache = new PageCache(baseUrl, regionSlug, languageSlug); } } class PageCache { - bySlug = new Map(); - byId = new Map(); + byId = new Map(); + byPath = new Map(); - pending = new Map(); + pending = new Map>(); baseUrl: string; regionSlug: string; @@ -359,7 +332,7 @@ class PageCache { / )? ([^/?#]+) - `.replace(/\s+/g, ""))); + `.replace(/\s+/g, "")); constructor(baseUrl: string, regionSlug: string, defaultLanguageSlug: string) { this.baseUrl = baseUrl; @@ -368,55 +341,81 @@ class PageCache { } cacheTranslationMetadata(translation: any) { - pageId = pageId || translation.page_id; - const [regionSlug, languageSlug, infix, _, slug] = translation.path.match(that._pathRegex); + const [fullURL, regionSlug, languageSlug, infix, _, slug] = translation.path.match(this._pathRegex); - const metadata = this.byId.get(pageId) || { - id: pageId, - parent: translation.parent.id, + const pageMetadata = this.byId.get(translation.page_id) || new PageMetadata({ + id: translation.page_id, + parentId: translation.parent?.id, regionSlug: regionSlug, - translations: new Map { + const that = this; async function inner(id: number) { - const url = `${this.baseUrl}/api/v3/${this.regionSlug}/${this.defaultLanguageSlug}/page/?id=${id}`; + const {languageSlug, translation} = await that._getPage(id); + const pageMetadata = that.cacheTranslationMetadata(translation); - const response = await fetch(url, { - method: "GET", - headers: { - "X-CSRFToken": getCsrfToken(), - }, - }) - const HTTP_STATUS_OK = 200; - if (response.status !== HTTP_STATUS_OK) { - return {}; - } - const translation = await response.json(); - const metadata = this.cacheTranslationMetadata(languageSlug, translation, translation.page_id); + // Pre-fill with languages Object.entries(translation.available_languages).forEach(([lang, tr]) => { - this.cacheTranslationMetadata(lang, tr, translation.page_id); + that.cacheTranslationMetadata(tr); }); - return metadata; + // Request detailed data + for (const [lang, tr] of Object.entries(translation.available_languages)) { + const {translation} = await that._getPage(id, lang); + that.cacheTranslationMetadata(translation); + } + if (ancestors && pageMetadata.parentId) { + await that.requestId(pageMetadata.parentId, ancestors); + } + return pageMetadata; } if (this.byId.has(id)) { @@ -424,11 +423,69 @@ class PageCache { return new Promise((res, rej) => res(this.byId.get(id))); } else if (!this.pending.has(id)) { // Start a new query - this.pending.set(id, inner(id, languageSlug)); + this.pending.set(id, inner(id)); } // Return the Promise of the ongoing query return this.pending.get(id); } } +class PageMetadata { + _cache: PageCache; + + id: number; + parentId: number; + regionSlug: string; + translations = new Map(); + + get parent() { + return this._cache?.byId.get(this.parentId); + } + + constructor(data: {id: number, parentId: number, regionSlug: string}, cache: PageCache = null) { + this._cache = cache; + this.id = data.id; + this.parentId = data.parentId; + this.regionSlug = data.regionSlug; + } +} + +class TranslationMetadata { + _cache: PageCache; + + id: number; + languageSlug: string; + title: string; + slug: string; + path: string; + page: PageMetadata; + + get parent() { + return this.page.parent?.translations.get(this.languageSlug); + } + get titlePath() { + const reverseTitles = [this.title]; + let translation: TranslationMetadata = this; + while (translation.page.parentId) { + translation = translation.parent; + if (translation) { + reverseTitles.push(translation.title); + } else { + reverseTitles.push("[?]"); + break; + } + } + return reverseTitles.reverse().join(" → "); + } + + constructor(data: {id: number, languageSlug: string, title: string, slug: string, path: string, page: PageMetadata}, cache: PageCache = null) { + this._cache = cache; + this.id = data.id; + this.languageSlug = data.languageSlug; + this.title = data.title; + this.slug = data.slug; + this.path = data.path; + this.page = data.page; + } +} export default PageHandle; diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 8efd39244a..05b9f8802a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -723,7 +723,7 @@ class ShortcodeHandle { this.tinymceConfig = document.getElementById("tinymce-config-options"); const closeContextToolbar = () => { - editor.fire("contexttoolbar-hide", { + editor.dispatch("contexttoolbar-hide", { toolbarKey: `shortcode_${this.keyword}_context_form`, }); }; From 571a77ee10fc8a0a8f794a03d124a954b7e55b73 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 9 Feb 2026 01:16:57 +0100 Subject: [PATCH 19/26] WIP fix other languages, cache population --- .../src/js/tinymce-plugins/shortcodes/page.ts | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index e4fc614b31..04b408964c 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -287,19 +287,15 @@ class PageHandle extends ShortcodeHandle { return []; } return response.json(); - }).then(data => { - data.forEach((translation: any) => { - this.pageCache.cacheTranslationMetadata(translation); - Object.entries(translation.available_languages).forEach(([lang, tr]) => { - this.pageCache.cacheTranslationMetadata(tr); - }); - }); + }).then(async (data) => { + for (let mainTranslation of data) { + await this.pageCache.requestId(mainTranslation.page_id); + }; }); } setup(editor: Editor) { super.setup(editor); - //this.populatePageCache(); this.editText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); @@ -307,6 +303,8 @@ class PageHandle extends ShortcodeHandle { const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); this.pageCache = new PageCache(baseUrl, regionSlug, languageSlug); + + //this.populatePageCache(); } } @@ -342,6 +340,7 @@ class PageCache { cacheTranslationMetadata(translation: any) { const [fullURL, regionSlug, languageSlug, infix, _, slug] = translation.path.match(this._pathRegex); + console.assert(translation.page_id, `translation without page id:`, translation); const pageMetadata = this.byId.get(translation.page_id) || new PageMetadata({ id: translation.page_id, @@ -352,7 +351,7 @@ class PageCache { this.byId.set(translation.page_id, pageMetadata); } - const translationMetadata = new TranslationMetadata({ + const translationMetadata = pageMetadata.translations.get(languageSlug) || new TranslationMetadata({ id: translation.id, languageSlug: languageSlug, title: translation.title, @@ -377,7 +376,7 @@ class PageCache { async _getPage(id: number, languageSlug: string = undefined) { languageSlug = languageSlug || this.defaultLanguageSlug; - const url = `${this.baseUrl}/api/v3/${this.regionSlug}/${this.defaultLanguageSlug}/page/?id=${id}`; + const url = `${this.baseUrl}/api/v3/${this.regionSlug}/${languageSlug}/page/?id=${id}`; const response = await fetch(url, { method: "GET", @@ -385,11 +384,7 @@ class PageCache { "X-CSRFToken": getCsrfToken(), }, }) - const HTTP_STATUS_OK = 200; - if (response.status !== HTTP_STATUS_OK) { - return {}; - } - const translation = await response.json(); + const translation = response.status === 200 ? await response.json() : null; return { id: id, languageSlug: languageSlug, @@ -401,16 +396,14 @@ class PageCache { const that = this; async function inner(id: number) { const {languageSlug, translation} = await that._getPage(id); + if (!translation) return null; const pageMetadata = that.cacheTranslationMetadata(translation); - // Pre-fill with languages - Object.entries(translation.available_languages).forEach(([lang, tr]) => { - that.cacheTranslationMetadata(tr); - }); - // Request detailed data for (const [lang, tr] of Object.entries(translation.available_languages)) { const {translation} = await that._getPage(id, lang); - that.cacheTranslationMetadata(translation); + if (translation) { + that.cacheTranslationMetadata(translation); + } } if (ancestors && pageMetadata.parentId) { await that.requestId(pageMetadata.parentId, ancestors); From 76807c8ca55951a5b240dec5aff830a55e0fc799 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 9 Feb 2026 01:33:15 +0100 Subject: [PATCH 20/26] WIP fix overwriting add/edit/remove things after object creation --- .../static/src/js/tinymce-plugins/shortcodes/page.ts | 3 +-- .../src/js/tinymce-plugins/shortcodes/utils.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 04b408964c..25c7e1055a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -30,8 +30,6 @@ class PageHandle extends ShortcodeHandle { ]; kwargs: KWargsDescriptor = []; - editText = ""; - domainAndPrefix: () => string = evaluateOnceDecorator(() => stripProtocol(this.tinymceConfig.getAttribute("data-webapp-url"))); // Regular expression to check íf a link could be a page // Capture groups: Path, Region slug, language slug, page infix, page slug @@ -297,6 +295,7 @@ class PageHandle extends ShortcodeHandle { setup(editor: Editor) { super.setup(editor); + this.addText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); this.editText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 05b9f8802a..2337a409d4 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -49,12 +49,12 @@ class ShortcodeHandle { readonly endword: string | null = null; editor: Editor; tinymceConfig: HTMLElement; - readonly addText: TextDescriptor = (self: ShortcodeHandle) => `Insert ${self.keyword}`; - readonly addIcon: string = "link"; - readonly editText: TextDescriptor = (self: ShortcodeHandle) => `Edit ${self.keyword}`; - readonly editIcon: string = "link"; - readonly removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; - readonly removeIcon: string = "unlink"; + addText: TextDescriptor = (self: ShortcodeHandle) => `Insert ${self.keyword}`; + addIcon: string = "link"; + editText: TextDescriptor = (self: ShortcodeHandle) => `Edit ${self.keyword}`; + editIcon: string = "link"; + removeText: TextDescriptor = (self: ShortcodeHandle) => `Remove ${self.keyword}`; + removeIcon: string = "unlink"; static escape(str: string | undefined): string { if (!str) return '""'; From aa7a0d0a1e68d0e88e42fd177786c6b9e7e7bd66 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 9 Feb 2026 13:04:50 +0100 Subject: [PATCH 21/26] WIP better preview --- .../cms/templates/_tinymce_config.html | 2 +- .../src/css/tinymce_custom_content_styles.css | 29 ++++++++++++++ .../src/js/tinymce-plugins/shortcodes/page.ts | 39 +++++++++++++++---- .../js/tinymce-plugins/shortcodes/utils.ts | 2 + 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/integreat_cms/cms/templates/_tinymce_config.html b/integreat_cms/cms/templates/_tinymce_config.html index 8c5e581522..5137095278 100644 --- a/integreat_cms/cms/templates/_tinymce_config.html +++ b/integreat_cms/cms/templates/_tinymce_config.html @@ -77,7 +77,7 @@ data-link-dialog-title-text='{% translate "Add Link" %}' data-link-dialog-url-text='{% translate "URL" %}' data-link-dialog-text-text='{% translate "Text to display" %}' - data-link-dialog-internal_link-text='{% translate "Or link to existing content" %}' + data-link-dialog-internal_link-text='{% translate "Search for a page" %}' data-link-dialog-autoupdate-text='{% translate "Automatically use the title of the linked content for the link" %}' data-custom-plugins="{% get_base_url %}{{ editor_content_js_files.0.url }}" data-content-css="{% get_base_url %}{{ editor_content_css_files.0.url }}" diff --git a/integreat_cms/static/src/css/tinymce_custom_content_styles.css b/integreat_cms/static/src/css/tinymce_custom_content_styles.css index e0f46c7a4c..58a2ce419e 100644 --- a/integreat_cms/static/src/css/tinymce_custom_content_styles.css +++ b/integreat_cms/static/src/css/tinymce_custom_content_styles.css @@ -5,6 +5,14 @@ @import "../fonts/noto-sans-georgian/noto-sans-georgian.css"; @import "./contact_card.css"; +/* Enforce expected standard link color, + so setting a different color on specific links to communicate changes + doesn't backfire in some edge case on some device where the defaults are changed */ +:link { color: #0000EE; } +:visited { color: #551A8B; } +:link:active, :visited:active { color: #FF0000; } + + [translate="no"]:not(.contact-card) { background-color: rgb(239, 68, 68); direction: ltr; @@ -19,3 +27,24 @@ font-family: "Open Sans" !important; font-size: 1rem !important; } + +/* Colored border version +[data-shortcode="page"] { + border: .15em solid rgba(0,0,255, 0.5); + border-radius: .2em; +} +[data-shortcode="page"]:has(.error) { + border-color: rgba(255,63,0, 0.5); +} +*/ + +/* Text color version */ +[data-shortcode="page"], [data-shortcode="page"] a { /* mention the link specifically so it binds stronger than :visited on the initialization */ + color: #008866; +} +[data-shortcode="page"]:has(.error) { + color: #CC6600; + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: #CC6600; +} diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 25c7e1055a..4afe8cafc4 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -79,10 +79,40 @@ class PageHandle extends ShortcodeHandle { renderPreviewNode(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode in the TinyMCE editor + // By default this is a span marked with mceNonEditable and the shortcode keyword and parameters + // and defers the visual presented to the user to be rendered to renderPreview() + + // Ensure the data for this is loaded in the cache, + // including ancestors, so the edit dialog can render the title path this.pageCache.requestId(parseInt(pargs[0]), true).then(); return super.renderPreviewNode(pargs, kwargs) } + renderPreview(pargs: string[], kwargs: Map): string { + // The html string representation of the shortcode preview in the TinyMCE editor + // By default this is just the canonical text representation. This function will be overwritten by most subclasses. + const id = parseInt(pargs[0]); + const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); + const page = this.pageCache.byId.get(id); + // TODO: If page not in cache, re-render after request resolved + const translation = page?.translations.get(languageSlug); + const text = pargs[1] || translation?.title; + + const TEXT_MISSING = "MISSING LINK"; // TODO: translations (#4044) + let element; + if (!translation) { + element = document.createElement("i"); + element.classList.add("error"); + element.innerText = `[${text || TEXT_MISSING}]`; + } else { + element = document.createElement("a"); + element.innerText = text; + // No href, the link is non-interactible anyway + element.href = "#" + } + return element.outerHTML; + } + async getCompletions(query: string, id: number) { const url = this.tinymceConfig.getAttribute("data-link-ajax-url"); const response = await fetch(url, { @@ -148,7 +178,7 @@ class PageHandle extends ShortcodeHandle { (completion) => completion.value === data[ID_ARG] ); // Don't set the completion text to `- no results -` - if (currentCompletion.value !== "") { + if (currentCompletion && currentCompletion.value !== "") { currentCompletionText = currentCompletion.title; } else { currentCompletionText = ""; @@ -362,13 +392,6 @@ class PageCache { this.byPath.set(translation.path, translationMetadata); - /* - if (!this.bySlug.has(languageSlug)) { - this.bySlug.set(languageSlug, new Map()); - } - this.bySlug.get(languageSlug).set(translation.path, pageMetadata); - */ - return pageMetadata; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index 2337a409d4..b9c4ef761a 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -365,6 +365,8 @@ class ShortcodeHandle { renderPreviewNode(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode in the TinyMCE editor + // By default this is a span marked with mceNonEditable and the shortcode keyword and parameters + // and defers the visual presented to the user to be rendered to renderPreview() const ppairs = pargs.map((arg, i) => `data-parg${i}=${ShortcodeHandle.escape(arg)}`); const kwpairs = this.sortKWargs(kwargs.entries()).map(([key, value]) => `data-kw-${key}=${ShortcodeHandle.escape(value)}`); const parts = [`class="mceNonEditable"`, `data-shortcode="${this.keyword}"`, ...ppairs, ...kwpairs]; From bc5a6bed54654392ec351644dd0a98ad24182adc Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 9 Feb 2026 15:54:43 +0100 Subject: [PATCH 22/26] WIP: make old link plugin not pop up toolbar for page links --- .../custom_link_input/plugin.js | 20 ++++++++++++++++++- .../src/js/tinymce-plugins/shortcodes/page.ts | 5 +---- .../static/src/js/utils/url-tools.ts | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 integreat_cms/static/src/js/utils/url-tools.ts diff --git a/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js b/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js index 0d00577df6..14cfd2e84f 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js +++ b/integreat_cms/static/src/js/tinymce-plugins/custom_link_input/plugin.js @@ -1,4 +1,5 @@ import { getCsrfToken } from "../../utils/csrf-token"; +import { stripProtocol } from "../../utils/url-tools"; (() => { const tinymceConfig = document.getElementById("tinymce-config-options"); @@ -47,7 +48,24 @@ import { getCsrfToken } from "../../utils/csrf-token"; }; tinymce.PluginManager.add("custom_link_input", (editor, _url) => { - const isAnchor = (node) => node.nodeName.toLowerCase() === "a" && node.href && node.isContentEditable; + const internalPageURLRegex = new RegExp(String.raw` + ^[^:/]*:// + ${stripProtocol(tinymceConfig.getAttribute("data-webapp-url")).replace(/\/$/, "")} + ( + / + ([^/]+) + / + ([^/]{2,8}) + / + ( + ([^?#]+) + / + )? + ([^/?#]+) + ) + `.replace(/\s+/g, "")); + + const isAnchor = (node) => node.nodeName.toLowerCase() === "a" && node.href && node.isContentEditable && !internalPageURLRegex.exec(node.href); const getAnchor = () => { let node = editor.selection.getNode(); while (node !== null) { diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 4afe8cafc4..49b986b235 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -2,10 +2,7 @@ import { ShortcodeHandle, AcceptArbitraryArguments, PargsDescriptor, KWargsDescr import type { ToolbarButtonInstanceApi, ContextFormInstanceApi, ContextFormButtonInstanceApi, ContextFormToggleButtonInstanceApi, DialogInstanceApi, DialogData, MenuItemInstanceApi, DialogSpec, BodyComponentSpec } from "../tinymce.d.ts"; import { Editor } from "tinymce"; import { getCsrfToken } from "../../utils/csrf-token"; - -function stripProtocol(url: string) { - return url.replace(/^[^:/]*:\/\//, ""); -} +import { stripProtocol } from "../../utils/url-tools"; function evaluateOnceDecorator(fn: ()=>T): ()=>T { let value: T | null = null; diff --git a/integreat_cms/static/src/js/utils/url-tools.ts b/integreat_cms/static/src/js/utils/url-tools.ts new file mode 100644 index 0000000000..73f5e07acc --- /dev/null +++ b/integreat_cms/static/src/js/utils/url-tools.ts @@ -0,0 +1,3 @@ +export function stripProtocol(url: string) { + return url.replace(/^[^:/]*:\/\//, ""); +} From 5fe8e70a1c80f1cd0396514a71fc3664cfef0f52 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Mon, 16 Feb 2026 14:39:46 +0100 Subject: [PATCH 23/26] WIP shortcodes --- .../js/tinymce-plugins/shortcodes/contact.ts | 51 +++++++++++++++++++ .../src/js/tinymce-plugins/shortcodes/page.ts | 16 ++---- .../tinymce-plugins/shortcodes/shortcodes.ts | 4 +- .../js/tinymce-plugins/shortcodes/utils.ts | 14 +++-- .../static/src/js/utils/caching-functions.ts | 12 +++++ 5 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 integreat_cms/static/src/js/utils/caching-functions.ts diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts index 63384b05f3..ec8764dcd6 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/contact.ts @@ -1,4 +1,10 @@ import { ShortcodeHandle, AcceptArbitraryArguments, PargsDescriptor, KWargsDescriptor } from "./utils"; +import type { ToolbarButtonInstanceApi, ContextFormInstanceApi, ContextFormButtonInstanceApi, ContextFormToggleButtonInstanceApi, DialogInstanceApi, DialogData, MenuItemInstanceApi, DialogSpec, BodyComponentSpec } from "../tinymce.d.ts"; +import { Editor } from "tinymce"; +import TomSelect from "tom-select"; +import { getCsrfToken } from "../../utils/csrf-token"; +import { stripProtocol } from "../../utils/url-tools"; +import { evaluateOnceDecorator } from "../../utils/caching-functions"; class ContactHandle extends ShortcodeHandle { keyword = "contact"; @@ -17,6 +23,51 @@ class ContactHandle extends ShortcodeHandle { ], ]; kwargs: KWargsDescriptor = []; + + domainAndPrefix: () => string = evaluateOnceDecorator(() => stripProtocol(this.tinymceConfig.getAttribute("data-webapp-url"))); + // Regular expression to check íf a link could be a page + // Capture groups: Path, Region slug, language slug, page infix, page slug + internalPageURLRegex: () => RegExp = evaluateOnceDecorator(() => new RegExp(String.raw` + ^[^:/]*:// + ${this.domainAndPrefix().replace(/\/$/, "")} + ( + / + ([^/]+) + / + ([^/]{2,8}) + / + ( + ([^?#]+) + / + )? + ([^/?#]+) + ) + `.replace(/\s+/g, ""))); + + //contactCache: ContactCache = null; + + predicate(node: Element): boolean { + // We also consider old contact cards as instances of the contact shortcode. + // This way shortcodes will work on the old contact cards and we will naturally slowly convert content from the old style directly embedded HTML. + if ("contactId" in (node as HTMLElement).dataset) { + return true; + } + return super.predicate(node); + } + + setup(editor: Editor): boolean { + super.setup(editor); + + this.addText = this.tinymceConfig.getAttribute("data-contact-menu-text"); + this.editText = this.tinymceConfig.getAttribute("data-contact-change-text"); + this.removeText = this.tinymceConfig.getAttribute("data-contact-remove-text"); + + const isContactsEnabled = this.tinymceConfig.getAttribute("data-contact-module-activated") !== "False"; + if (!isContactsEnabled) { + return false; + } + return true; + } } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 49b986b235..e8a730a7a1 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -3,19 +3,7 @@ import type { ToolbarButtonInstanceApi, ContextFormInstanceApi, ContextFormButto import { Editor } from "tinymce"; import { getCsrfToken } from "../../utils/csrf-token"; import { stripProtocol } from "../../utils/url-tools"; - -function evaluateOnceDecorator(fn: ()=>T): ()=>T { - let value: T | null = null; - // Save whether we computed the value as a separate boolean, in case we ever literally compute null - let computed = false; - return (): T => { - if (!computed) { - value = fn(); - computed = true; - } - return value; - }; -}; +import { evaluateOnceDecorator } from "../../utils/caching-functions"; class PageHandle extends ShortcodeHandle { @@ -324,6 +312,7 @@ class PageHandle extends ShortcodeHandle { this.addText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); this.editText = this.tinymceConfig.getAttribute("data-link-dialog-title-text"); + this.removeText = this.tinymceConfig.getAttribute("data-link-remove-text"); const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); @@ -331,6 +320,7 @@ class PageHandle extends ShortcodeHandle { this.pageCache = new PageCache(baseUrl, regionSlug, languageSlug); //this.populatePageCache(); + return true; } } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts index 15ffd7a78e..43f697ca82 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/shortcodes.ts @@ -1,6 +1,8 @@ /******************************************* * JS version of pythons shortcode package * - * which is licensed under MIT * + * which is licensed under MIT. * + * This ensures that editor and backend * + * behave exactly the same. * *******************************************/ diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index b9c4ef761a..b782c07081 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -580,7 +580,7 @@ class ShortcodeHandle { }; console.log(`[${this.keyword}]`, this, dialogConfig); - setTimeout(this.defaultEditDialogRefinement, 0); + setTimeout(this.defaultEditDialogRefinement.bind(this), 0); return this.editor.windowManager.open(dialogConfig); } @@ -716,7 +716,10 @@ class ShortcodeHandle { } } - setup(editor: Editor) { + setup(editor: Editor): boolean { + /* Method to initialize the instance for an editor. + * Only if setup() returns true, the shortcode will be enabled. + */ /* default behavior: * - menu item to insert shortcode → open dialog * - context toolbar with edit and delete @@ -766,6 +769,8 @@ class ShortcodeHandle { scope: "node", items: `edit_shortcode_${this.keyword} remove_shortcode_${this.keyword}`, }); + + return true; } } @@ -821,8 +826,9 @@ class Registry { public static setupAll(editor: Editor, parser: Parser) { Registry.instance.handles.forEach((value: ShortcodeHandle, key: string) => { - value.setup(editor); - parser.register(value.renderPreviewNode.bind(value), key, value.endword); + if (value.setup(editor)) { + parser.register(value.renderPreviewNode.bind(value), key, value.endword); + } }); if (this.instance.unknownHandleFactory !== null) { parser.setUnknownHandlerFactory((keyword: string) => { diff --git a/integreat_cms/static/src/js/utils/caching-functions.ts b/integreat_cms/static/src/js/utils/caching-functions.ts new file mode 100644 index 0000000000..b6a00de28a --- /dev/null +++ b/integreat_cms/static/src/js/utils/caching-functions.ts @@ -0,0 +1,12 @@ +export function evaluateOnceDecorator(fn: ()=>T): ()=>T { + let value: T | null = null; + // Save whether we computed the value as a separate boolean, in case we ever literally compute null + let computed = false; + return (): T => { + if (!computed) { + value = fn(); + computed = true; + } + return value; + }; +}; From a29e0d09cfecb2ddb5f31361b8c2aa863e3e57f5 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Tue, 28 Apr 2026 14:15:14 +0200 Subject: [PATCH 24/26] WIP refresh preview nodes after fetching page metadata --- .../src/js/tinymce-plugins/shortcodes/page.ts | 20 +++++++++++++----- .../js/tinymce-plugins/shortcodes/utils.ts | 20 ++++++++++++++++++ integreat_cms/static/src/js/utils/debounce.ts | 21 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 integreat_cms/static/src/js/utils/debounce.ts diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index e8a730a7a1..69f714a5a8 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -5,6 +5,8 @@ import { getCsrfToken } from "../../utils/csrf-token"; import { stripProtocol } from "../../utils/url-tools"; import { evaluateOnceDecorator } from "../../utils/caching-functions"; +import { debounce } from "../../utils/debounce"; + class PageHandle extends ShortcodeHandle { keyword = "page"; @@ -139,10 +141,10 @@ class PageHandle extends ShortcodeHandle { value: "", }; const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); - const cachedPageData = this.pageCache.byId.get(parseInt(initialPargs[0])).translations.get(languageSlug); + const cachedPageData = this.pageCache.byId.get(parseInt(initialPargs[0]))?.translations.get(languageSlug); const initialCompletionItem = { - text: cachedPageData.titlePath, - title: cachedPageData.title, + text: cachedPageData?.titlePath || "", + title: cachedPageData?.title || "", value: `${initialPargs[0]}`, }; const completionItems = cachedPageData ? [initialCompletionItem] : [defaultCompletionItem]; @@ -317,7 +319,11 @@ class PageHandle extends ShortcodeHandle { const baseUrl = this.tinymceConfig.getAttribute("data-base-url"); const regionSlug = this.tinymceConfig.getAttribute("data-region-slug"); const languageSlug = this.tinymceConfig.getAttribute("data-language-slug"); - this.pageCache = new PageCache(baseUrl, regionSlug, languageSlug); + + const debouncedRefreshPreview = debounce((pageMetadata: PageMetadata) => { + this.refreshPreview((pargs, kwargs) => parseInt(pargs[0]) == pageMetadata.id); + }, 100); + this.pageCache = new PageCache(baseUrl, regionSlug, languageSlug, debouncedRefreshPreview); //this.populatePageCache(); return true; @@ -331,6 +337,8 @@ class PageCache { pending = new Map>(); + afterFetch: ((metadata: PageMetadata) => void) | null = null; + baseUrl: string; regionSlug: string; defaultLanguageSlug: string; @@ -348,10 +356,11 @@ class PageCache { ([^/?#]+) `.replace(/\s+/g, "")); - constructor(baseUrl: string, regionSlug: string, defaultLanguageSlug: string) { + constructor(baseUrl: string, regionSlug: string, defaultLanguageSlug: string, afterFetch: ((metadata: PageMetadata) => void) | null = null) { this.baseUrl = baseUrl; this.regionSlug = regionSlug; this.defaultLanguageSlug = defaultLanguageSlug; + this.afterFetch = afterFetch; } cacheTranslationMetadata(translation: any) { @@ -379,6 +388,7 @@ class PageCache { this.byPath.set(translation.path, translationMetadata); + if (this.afterFetch) this.afterFetch(pageMetadata); return pageMetadata; } diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts index b782c07081..92bef89cf1 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/utils.ts @@ -373,12 +373,32 @@ class ShortcodeHandle { return `${this.renderPreview(pargs, kwargs)}`; } + findNodes(): NodeListOf { + return this.editor.contentDocument.querySelectorAll(`[data-shortcode="${this.keyword}"]`); + } + renderPreview(pargs: string[], kwargs: Map): string { // The html string representation of the shortcode preview in the TinyMCE editor // By default this is just the canonical text representation. This function will be overwritten by most subclasses. return this.renderShortcode(pargs, kwargs); } + refreshPreview(predicate: ((pargs: string[], kwargs: Map) => boolean) | undefined) { + const previousSelection = this.editor.selection.getBookmark(); + + this.findNodes().forEach(((node: HTMLElement) => { + const [pargs, kwargs] = this.argsFromNode(node); + if (predicate && !predicate(pargs, kwargs)) return; + + this.editor.selection.select(node); + node.remove(); + this.editor.insertContent(this.renderShortcode(pargs, new Map(Object.entries(kwargs)))); + }).bind(this)); + + // Restore selection + this.editor.selection.moveToBookmark(previousSelection); + } + reconstructArgsFromDialog(api: DialogInstanceApi): [string[], {[key: string]: string}, [string, string][]] { // Reconstruct the positional and keyword arguments from the form data const data = api.getData(); diff --git a/integreat_cms/static/src/js/utils/debounce.ts b/integreat_cms/static/src/js/utils/debounce.ts new file mode 100644 index 0000000000..e75c5c97df --- /dev/null +++ b/integreat_cms/static/src/js/utils/debounce.ts @@ -0,0 +1,21 @@ + +export function debounce< + Fn extends (this: any, ...args: any[]) => any +>( + func: Fn, + wait: number, + immediate: boolean = false +): (...args: Parameters) => void | undefined { + let timeout: number | undefined; + return function(this: ThisParameterType, ...args: Parameters) { + const callNow = immediate && !timeout; + window.clearTimeout(timeout); + timeout = window.setTimeout(() => { + timeout = undefined; + if (!immediate) { + func.apply(this, args); + } + }, wait); + if (callNow) func.apply(this, args); + } +} From 8c7df674f6f453f33b7a16e9e002812adc4c4509 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Tue, 28 Apr 2026 14:23:58 +0200 Subject: [PATCH 25/26] WIP hint at unimplemented lang kwarg --- .../static/src/js/tinymce-plugins/shortcodes/page.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 69f714a5a8..09ebea2112 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -15,7 +15,10 @@ class PageHandle extends ShortcodeHandle { [["id", "The ID of the page to link to"]], [["text", "The text to display (if not specified, show page title)"]], ]; - kwargs: KWargsDescriptor = []; + kwargs: KWargsDescriptor = [ + // TODO: implement lang kwarg + // [["lang", false, "The language to which to link (instead of preferring the language of the document containing this link)", "Language"]], + ]; domainAndPrefix: () => string = evaluateOnceDecorator(() => stripProtocol(this.tinymceConfig.getAttribute("data-webapp-url"))); // Regular expression to check íf a link could be a page From 3b9d303d2548c9c26ea99a6498f1f8d8dae4e922 Mon Sep 17 00:00:00 2001 From: Peter Nerlich Date: Wed, 13 May 2026 14:35:39 +0200 Subject: [PATCH 26/26] WIP adjust tooltip texts for page shortcode --- .../static/src/js/tinymce-plugins/shortcodes/page.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts index 09ebea2112..6b35c2ade7 100644 --- a/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts +++ b/integreat_cms/static/src/js/tinymce-plugins/shortcodes/page.ts @@ -20,6 +20,10 @@ class PageHandle extends ShortcodeHandle { // [["lang", false, "The language to which to link (instead of preferring the language of the document containing this link)", "Language"]], ]; + addText = "Add Page Link" + editText = "Edit Page Link" + removeText = "Remove Page Link" + domainAndPrefix: () => string = evaluateOnceDecorator(() => stripProtocol(this.tinymceConfig.getAttribute("data-webapp-url"))); // Regular expression to check íf a link could be a page // Capture groups: Path, Region slug, language slug, page infix, page slug