import { MAIN_PLUGINS } from "./plugin_sets"; import { removeClass } from "./utils/dom"; import { isEmpty } from "./utils/dom_info"; import { resourceSequenceSymbol, withSequence } from "./utils/resource"; import { initElementForEdition } from "./utils/sanitize"; /** * @typedef { import("./plugin_sets").SharedMethods } SharedMethods * @typedef {typeof import("./plugin").Plugin} PluginConstructor **/ /** * @typedef { Object } CollaborationConfig * @property { string } collaboration.peerId * @property { Object } collaboration.busService * @property { Object } collaboration.collaborationChannel * @property { String } collaboration.collaborationChannel.collaborationModelName * @property { String } collaboration.collaborationChannel.collaborationFieldName * @property { Number } collaboration.collaborationChannel.collaborationResId * @property { 'start' | 'focus' } [collaboration.collaborativeTrigger] * @typedef { Object } EditorConfig * @property { string } [content] * @property { boolean } [allowInlineAtRoot] * @property { PluginConstructor[] } [Plugins] * @property { boolean } [disableFloatingToolbar] * @property { string[] } [classList] * @property { Object } [localOverlayContainers] * @property { Object } [embeddedComponentInfo] * @property { Object } [resources] * @property { string } [direction="ltr"] * @property { Function } [onChange] * @property { Function } [onEditorReady] * @property { boolean } [dropImageAsAttachment] * @property { CollaborationConfig } [collaboration] * @property { Function } getRecordInfo */ function sortPlugins(plugins) { const initialPlugins = new Set(plugins); const inResult = new Set(); // need to sort them const result = []; let P; function findPlugin() { for (const P of initialPlugins) { if (P.dependencies.every((dep) => inResult.has(dep))) { initialPlugins.delete(P); return P; } } } while ((P = findPlugin())) { inResult.add(P.id); result.push(P); } if (initialPlugins.size) { const messages = []; for (const P of initialPlugins) { messages.push( `"${P.id}" is missing (${P.dependencies .filter((d) => !inResult.has(d)) .join(", ")})` ); } throw new Error(`Missing dependencies: ${messages.join(", ")}`); } return result; } export class Editor { /** * @param { EditorConfig } config */ constructor(config, services) { this.isDestroyed = false; this.config = config; this.services = services; this.resources = null; this.plugins = []; /** @type { HTMLElement } **/ this.editable = null; /** @type { Document } **/ this.document = null; /** @ts-ignore @type { SharedMethods } **/ this.shared = {}; } attachTo(editable) { if (this.isDestroyed || this.editable) { throw new Error("Cannot re-attach an editor"); } this.editable = editable; this.document = editable.ownerDocument; if (this.config.content) { editable.innerHTML = this.config.content; if (isEmpty(editable)) { editable.innerHTML = "


"; } } this.preparePlugins(); editable.setAttribute("contenteditable", true); initElementForEdition(editable, { allowInlineAtRoot: !!this.config.allowInlineAtRoot }); editable.classList.add("odoo-editor-editable"); if (this.config.classList) { editable.classList.add(...this.config.classList); } if (this.config.height) { editable.style.height = this.config.height; } this.startPlugins(); this.config.onEditorReady?.(); } preparePlugins() { const Plugins = sortPlugins(this.config.Plugins || MAIN_PLUGINS); const plugins = new Map(); for (const P of Plugins) { if (P.id === "") { throw new Error(`Missing plugin id (class ${P.name})`); } if (plugins.has(P.id)) { throw new Error(`Duplicate plugin id: ${P.id}`); } const imports = {}; for (const dep of P.dependencies) { if (plugins.has(dep)) { imports[dep] = {}; for (const h of plugins.get(dep).shared) { imports[dep][h] = this.shared[dep][h]; } } else { throw new Error(`Missing dependency for plugin ${P.id}: ${dep}`); } } plugins.set(P.id, P); const plugin = new P(this.document, this.editable, imports, this.config, this.services); this.plugins.push(plugin); const exports = {}; for (const h of P.shared) { if (!(h in plugin)) { throw new Error(`Missing helper implementation: ${h} in plugin ${P.id}`); } exports[h] = plugin[h].bind(plugin); } this.shared[P.id] = exports; } const resources = this.createResources(); for (const plugin of this.plugins) { plugin._resources = resources; } this.resources = resources; } startPlugins() { for (const plugin of this.plugins) { plugin.setup(); } this.resources["normalize_handlers"].forEach((cb) => cb(this.editable)); this.resources["start_edition_handlers"].forEach((cb) => cb()); } createResources() { const resources = {}; function registerResources(obj) { for (const key in obj) { if (!(key in resources)) { resources[key] = []; } resources[key].push(obj[key]); } } if (this.config.resources) { registerResources(this.config.resources); } for (const plugin of this.plugins) { if (plugin.resources) { registerResources(plugin.resources); } } for (const key in resources) { const resource = resources[key] .flat() .map((r) => { const isObjectWithSequence = typeof r === "object" && r !== null && resourceSequenceSymbol in r; return isObjectWithSequence ? r : withSequence(10, r); }) .sort((a, b) => a[resourceSequenceSymbol] - b[resourceSequenceSymbol]) .map((r) => r.object); resources[key] = resource; Object.freeze(resources[key]); } return Object.freeze(resources); } getContent() { return this.getElContent().innerHTML; } getElContent() { const el = this.editable.cloneNode(true); this.resources["clean_for_save_handlers"].forEach((cb) => cb({ root: el })); return el; } destroy(willBeRemoved) { if (this.editable) { let plugin; while ((plugin = this.plugins.pop())) { plugin.destroy(); } this.shared = {}; if (!willBeRemoved) { // we only remove class/attributes when necessary. If we know that the editable // element will be removed, no need to make changes that may require the browser // to recompute the layout this.editable.removeAttribute("contenteditable"); removeClass(this.editable, "odoo-editor-editable"); } this.editable = null; } this.isDestroyed = true; } }