diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py
index 84f7f4d82ad..4360bb69617 100644
--- a/addons/web/__manifest__.py
+++ b/addons/web/__manifest__.py
@@ -478,6 +478,16 @@ This module provides the core of the Odoo Web Client.
'web/static/src/webclient/actions/reports/reset.min.css',
],
+ 'web.ace_lib': [
+ "web/static/lib/ace/ace.js",
+ "web/static/lib/ace/mode-js.js",
+ "web/static/lib/ace/javascript_highlight_rules.js",
+ "web/static/lib/ace/mode-xml.js",
+ "web/static/lib/ace/mode-qweb.js",
+ "web/static/lib/ace/mode-scss.js",
+ "web/static/lib/ace/theme-monokai.js",
+ ],
+
# ---------------------------------------------------------------------
# COLOR SCHEME BUNDLES
# ---------------------------------------------------------------------
diff --git a/addons/web/static/src/core/code_editor/code_editor.js b/addons/web/static/src/core/code_editor/code_editor.js
new file mode 100644
index 00000000000..26cf6e4faec
--- /dev/null
+++ b/addons/web/static/src/core/code_editor/code_editor.js
@@ -0,0 +1,110 @@
+/** @odoo-module */
+import { Component, onWillDestroy, onWillStart, useEffect, useRef } from "@odoo/owl";
+import { getBundle, loadBundle } from "@web/core/assets";
+import { useDebounced } from "@web/core/utils/timing";
+
+function onResized(ref, callback) {
+ const _ref = typeof ref === "string" ? useRef(ref) : ref;
+ const resizeObserver = new ResizeObserver(callback);
+
+ useEffect(
+ (el) => {
+ if (el) {
+ resizeObserver.observe(el);
+ return () => resizeObserver.unobserve(el);
+ }
+ },
+ () => [_ref.el]
+ );
+
+ onWillDestroy(() => {
+ resizeObserver.disconnect();
+ });
+}
+
+export class CodeEditor extends Component {
+ static template = "web.CodeEditor";
+ static components = {};
+ static props = {
+ mode: {
+ type: String,
+ optional: true,
+ validate: (mode) => CodeEditor.MODES.includes(mode),
+ },
+ value: { type: String, optional: true },
+ onChange: { type: Function, optional: true },
+ class: { type: String, optional: true },
+ theme: {
+ type: String,
+ optional: true,
+ validate: (theme) => CodeEditor.THEMES.includes(theme),
+ },
+ };
+ static defaultProps = {
+ value: "",
+ onChange: () => {},
+ class: "",
+ theme: "",
+ };
+
+ static MODES = ["js", "xml", "qweb", "scss", "python"];
+ static THEMES = ["", "monokai"];
+
+ setup() {
+ this.editorRef = useRef("editorRef");
+
+ onWillStart(async () => loadBundle(await getBundle("web.ace_lib")));
+
+ useEffect(
+ (el) => {
+ if (!el) {
+ return;
+ }
+
+ // keep in closure
+ const aceEditor = window.ace.edit(el);
+ this.aceEditor = aceEditor;
+ this.aceEditor.session.setUseWorker(false);
+ this.aceEditor.setValue(this.props.value);
+
+ this.aceEditor.session.on("change", () => {
+ if (this.props.onChange) {
+ this.props.onChange(this.aceEditor.getValue());
+ }
+ });
+
+ return () => {
+ aceEditor.destroy();
+ };
+ },
+ () => [this.editorRef.el]
+ );
+
+ useEffect(
+ (value) => {
+ if (value !== this.aceEditor.getValue()) {
+ this.aceEditor.setValue(value);
+ }
+ },
+ () => [this.props.value]
+ );
+
+ useEffect(
+ (mode) => this.aceEditor.session.setMode(mode ? `ace/mode/${mode}` : ""),
+ () => [this.props.mode]
+ );
+
+ useEffect(
+ (theme) => this.aceEditor.setTheme(theme ? `ace/theme/${theme}` : "textmate"),
+ () => [this.props.theme]
+ );
+
+ this.debouncedResize = useDebounced(() => {
+ if (this.aceEditor) {
+ this.aceEditor.resize();
+ }
+ }, 250);
+
+ onResized(this.editorRef, () => this.debouncedResize());
+ }
+}
diff --git a/addons/web/static/src/core/code_editor/code_editor.xml b/addons/web/static/src/core/code_editor/code_editor.xml
new file mode 100644
index 00000000000..73ff2205470
--- /dev/null
+++ b/addons/web/static/src/core/code_editor/code_editor.xml
@@ -0,0 +1,9 @@
+
+
A Paragraph
\nAn Other Paragraph
\nA Paragraph
\nAnd More
"; + + class Parent extends Component { + static components = { CodeEditor }; + static template = xml` +