diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js
index 20193db7f..7f180fc07 100644
--- a/dev/build/esbuild.js
+++ b/dev/build/esbuild.js
@@ -14,7 +14,7 @@ const entryPoints = {
     code: path.join(__dirname, '../../resources/js/code/index.mjs'),
     'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
     markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
-    wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.mjs'),
+    wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
 };
 
 // Locate our output directory
diff --git a/package-lock.json b/package-lock.json
index 6cd32760d..2b6b677c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,7 +43,8 @@
         "eslint-plugin-import": "^2.29.0",
         "livereload": "^0.9.3",
         "npm-run-all": "^4.1.5",
-        "sass": "^1.69.5"
+        "sass": "^1.69.5",
+        "typescript": "^5.4.5"
       }
     },
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -4099,6 +4100,19 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/typescript": {
+      "version": "5.4.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+      "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+      "dev": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
     "node_modules/uc.micro": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
diff --git a/package.json b/package.json
index 42b86fdc7..97a796126 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
     "eslint-plugin-import": "^2.29.0",
     "livereload": "^0.9.3",
     "npm-run-all": "^4.1.5",
-    "sass": "^1.69.5"
+    "sass": "^1.69.5",
+    "typescript": "^5.4.5"
   },
   "dependencies": {
     "@codemirror/commands": "^6.3.2",
@@ -65,7 +66,8 @@
     },
     "extends": "airbnb-base",
     "ignorePatterns": [
-      "resources/**/*-stub.js"
+      "resources/**/*-stub.js",
+      "resources/**/*.ts"
     ],
     "overrides": [],
     "parserOptions": {
diff --git a/resources/js/wysiwyg/index.mjs b/resources/js/wysiwyg/index.ts
similarity index 83%
rename from resources/js/wysiwyg/index.mjs
rename to resources/js/wysiwyg/index.ts
index decfa4f22..266866c62 100644
--- a/resources/js/wysiwyg/index.mjs
+++ b/resources/js/wysiwyg/index.ts
@@ -4,18 +4,18 @@ import {
     $getSelection,
     COMMAND_PRIORITY_LOW,
     createCommand,
-    createEditor
+    createEditor, CreateEditorArgs,
 } from 'lexical';
 import {createEmptyHistoryState, registerHistory} from '@lexical/history';
 import {registerRichText} from '@lexical/rich-text';
 import {$getNearestBlockElementAncestorOrThrow, mergeRegister} from '@lexical/utils';
 import {$generateNodesFromDOM} from '@lexical/html';
-import {getNodesForPageEditor} from "./nodes/index.js";
-import {$createCalloutNode, $isCalloutNode} from "./nodes/callout.js";
-import {$setBlocksType} from "@lexical/selection";
+import {$setBlocksType} from '@lexical/selection';
+import {getNodesForPageEditor} from './nodes';
+import {$createCalloutNode, $isCalloutNode, CalloutCategory} from './nodes/callout';
 
-export function createPageEditorInstance(editArea) {
-    const config = {
+export function createPageEditorInstance(editArea: HTMLElement) {
+    const config: CreateEditorArgs = {
         namespace: 'BookStackPageEditor',
         nodes: getNodesForPageEditor(),
         onError: console.error,
@@ -52,7 +52,7 @@ export function createPageEditorInstance(editArea) {
     // Example of creating, registering and using a custom command
 
     const SET_BLOCK_CALLOUT_COMMAND = createCommand();
-    editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category = 'info') => {
+    editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = 'info') => {
         const selection = $getSelection();
         const blockElement = $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]);
         if ($isCalloutNode(blockElement)) {
@@ -67,4 +67,4 @@ export function createPageEditorInstance(editArea) {
     button.addEventListener('click', event => {
         editor.dispatchCommand(SET_BLOCK_CALLOUT_COMMAND, 'info');
     });
-}
\ No newline at end of file
+}
diff --git a/resources/js/wysiwyg/nodes/callout.js b/resources/js/wysiwyg/nodes/callout.ts
similarity index 58%
rename from resources/js/wysiwyg/nodes/callout.js
rename to resources/js/wysiwyg/nodes/callout.ts
index db90f22a9..4fba5ee5b 100644
--- a/resources/js/wysiwyg/nodes/callout.js
+++ b/resources/js/wysiwyg/nodes/callout.ts
@@ -1,35 +1,51 @@
-import {$createParagraphNode, ElementNode} from 'lexical';
+import {
+    $createParagraphNode,
+    DOMConversion,
+    DOMConversionMap, DOMConversionOutput,
+    ElementNode,
+    LexicalEditor,
+    LexicalNode,
+    ParagraphNode, SerializedElementNode, Spread
+} from 'lexical';
+import type {EditorConfig} from "lexical/LexicalEditor";
+import type {RangeSelection} from "lexical/LexicalSelection";
+
+export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success';
+
+export type SerializedCalloutNode = Spread<{
+    category: CalloutCategory;
+}, SerializedElementNode>
 
 export class Callout extends ElementNode {
 
-    __category = 'info';
+    __category: CalloutCategory = 'info';
 
     static getType() {
         return 'callout';
     }
 
-    static clone(node) {
+    static clone(node: Callout) {
         return new Callout(node.__category, node.__key);
     }
 
-    constructor(category, key) {
+    constructor(category: CalloutCategory, key?: string) {
         super(key);
         this.__category = category;
     }
 
-    createDOM(_config, _editor) {
+    createDOM(_config: EditorConfig, _editor: LexicalEditor) {
         const element = document.createElement('p');
         element.classList.add('callout', this.__category || '');
         return element;
     }
 
-    updateDOM(prevNode, dom) {
+    updateDOM(prevNode: unknown, dom: HTMLElement) {
         // Returning false tells Lexical that this node does not need its
         // DOM element replacing with a new copy from createDOM.
         return false;
     }
 
-    insertNewAfter(selection, restoreSelection) {
+    insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): Callout|ParagraphNode {
         const anchorOffset = selection ? selection.anchor.offset : 0;
         const newElement = anchorOffset === this.getTextContentSize() || !selection
             ? $createParagraphNode() : $createCalloutNode(this.__category);
@@ -46,14 +62,14 @@ export class Callout extends ElementNode {
         return newElement;
     }
 
-    static importDOM() {
+    static importDOM(): DOMConversionMap|null {
         return {
-            p: node => {
+            p(node: HTMLElement): DOMConversion|null {
                 if (node.classList.contains('callout')) {
                     return {
-                        conversion: element => {
-                            let category = 'info';
-                            const categories = ['info', 'success', 'warning', 'danger'];
+                        conversion: (element: HTMLElement): DOMConversionOutput|null => {
+                            let category: CalloutCategory = 'info';
+                            const categories: CalloutCategory[] = ['info', 'success', 'warning', 'danger'];
 
                             for (const c of categories) {
                                 if (element.classList.contains(c)) {
@@ -74,7 +90,7 @@ export class Callout extends ElementNode {
         };
     }
 
-    exportJSON() {
+    exportJSON(): SerializedCalloutNode {
         return {
             ...super.exportJSON(),
             type: 'callout',
@@ -83,16 +99,16 @@ export class Callout extends ElementNode {
         };
     }
 
-    static importJSON(serializedNode) {
+    static importJSON(serializedNode: SerializedCalloutNode): Callout {
         return $createCalloutNode(serializedNode.category);
     }
 
 }
 
-export function $createCalloutNode(category = 'info') {
+export function $createCalloutNode(category: CalloutCategory = 'info') {
     return new Callout(category);
 }
 
-export function $isCalloutNode(node) {
+export function $isCalloutNode(node: LexicalNode | null | undefined) {
     return node instanceof Callout;
 }
diff --git a/resources/js/wysiwyg/nodes/index.js b/resources/js/wysiwyg/nodes/index.ts
similarity index 60%
rename from resources/js/wysiwyg/nodes/index.js
rename to resources/js/wysiwyg/nodes/index.ts
index ada229d9e..77f582877 100644
--- a/resources/js/wysiwyg/nodes/index.js
+++ b/resources/js/wysiwyg/nodes/index.ts
@@ -1,11 +1,11 @@
 import {HeadingNode, QuoteNode} from '@lexical/rich-text';
 import {Callout} from './callout';
+import {KlassConstructor, LexicalNode} from "lexical";
 
 /**
  * Load the nodes for lexical.
- * @returns {LexicalNode[]}
  */
-export function getNodesForPageEditor() {
+export function getNodesForPageEditor(): KlassConstructor<typeof LexicalNode>[] {
     return [
         Callout,
         HeadingNode,