diff --git a/core-web/.claude/settings.json b/core-web/.claude/settings.json
index a2fdc2bd4e9e..07cd2a069741 100644
--- a/core-web/.claude/settings.json
+++ b/core-web/.claude/settings.json
@@ -7,6 +7,9 @@
}
}
},
+ "env": {
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
+ },
"enabledPlugins": {
"nx@nx-claude-plugins": true
}
diff --git a/core-web/apps/dotcms-block-editor/project.json b/core-web/apps/dotcms-block-editor/project.json
index 2ac11fb1a1ed..7d197cac2f7a 100644
--- a/core-web/apps/dotcms-block-editor/project.json
+++ b/core-web/apps/dotcms-block-editor/project.json
@@ -7,13 +7,19 @@
"tags": ["skip:test", "skip:lint"],
"targets": {
"build": {
- "executor": "@angular-devkit/build-angular:browser-esbuild",
+ "executor": "@angular/build:application",
+ "outputs": ["{options.outputPath.base}"],
"options": {
- "outputPath": "dist/apps/dotcms-block-editor",
+ "baseHref": "./",
+ "outputPath": {
+ "base": "dist/apps/dotcms-block-editor",
+ "browser": ""
+ },
"index": "apps/dotcms-block-editor/src/index.html",
- "main": "apps/dotcms-block-editor/src/main.ts",
- "polyfills": "apps/dotcms-block-editor/src/polyfills.ts",
+ "browser": "apps/dotcms-block-editor/src/main.ts",
+ "polyfills": ["apps/dotcms-block-editor/src/polyfills.ts"],
"tsConfig": "apps/dotcms-block-editor/tsconfig.app.json",
+ "inlineStyleLanguage": "css",
"assets": [
"apps/dotcms-block-editor/src/favicon.ico",
"apps/dotcms-block-editor/src/assets"
@@ -33,24 +39,29 @@
"includePaths": ["libs/dotcms-scss/angular"]
},
"allowedCommonJsDependencies": ["lodash.isequal", "date-fns"],
- "vendorChunk": true,
"extractLicenses": false,
- "buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
+ "development": {
+ "optimization": false,
+ "sourceMap": true,
+ "namedChunks": true,
+ "extractLicenses": false
+ },
"localhost": {
"sourceMap": true,
- "optimization": false,
- "watch": true
+ "optimization": false
},
"tomcat": {
- "outputPath": "../../tomcat9/webapps/ROOT/dotcms-block-editor",
+ "outputPath": {
+ "base": "../../tomcat9/webapps/ROOT/dotcms-block-editor",
+ "browser": ""
+ },
"sourceMap": true,
- "optimization": false,
- "watch": true
+ "optimization": false
},
"production": {
"fileReplacements": [
@@ -64,8 +75,6 @@
"sourceMap": false,
"namedChunks": false,
"extractLicenses": false,
- "vendorChunk": false,
- "buildOptimizer": true,
"budgets": [
{
"type": "initial",
@@ -85,7 +94,7 @@
"serve": {
"executor": "@angular/build:dev-server",
"options": {
- "buildTarget": "dotcms-block-editor:build"
+ "buildTarget": "dotcms-block-editor:build:development"
},
"configurations": {
"production": {
@@ -113,7 +122,7 @@
"main": "apps/dotcms-block-editor/src/test.ts",
"tsConfig": "apps/dotcms-block-editor/tsconfig.spec.json",
"karmaConfig": "apps/dotcms-block-editor/karma.conf.js",
- "polyfills": "apps/dotcms-block-editor/src/polyfills.ts",
+ "polyfills": ["apps/dotcms-block-editor/src/polyfills.ts"],
"styles": [],
"scripts": [],
"assets": []
diff --git a/core-web/apps/dotcms-block-editor/src/app/app.component.ts b/core-web/apps/dotcms-block-editor/src/app/app.component.ts
index 80797f2be3cd..3c85a604fcdb 100644
--- a/core-web/apps/dotcms-block-editor/src/app/app.component.ts
+++ b/core-web/apps/dotcms-block-editor/src/app/app.component.ts
@@ -1,11 +1,11 @@
import { Component } from '@angular/core';
+import { EditorComponent } from '@dotcms/new-block-editor';
@Component({
selector: 'dotcms-root',
templateUrl: './app.component.html',
styleUrls: [],
- standalone: false
+ imports: [EditorComponent],
+ standalone: true
})
-export class AppComponent {
- title = 'dotcms-block-editor';
-}
+export class AppComponent {}
diff --git a/core-web/apps/dotcms-block-editor/src/app/app.config.ts b/core-web/apps/dotcms-block-editor/src/app/app.config.ts
new file mode 100644
index 000000000000..b05ceb86b8b3
--- /dev/null
+++ b/core-web/apps/dotcms-block-editor/src/app/app.config.ts
@@ -0,0 +1,31 @@
+import Lara from '@primeuix/themes/lara';
+
+import { provideHttpClient } from '@angular/common/http';
+import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
+import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
+
+import { providePrimeNG } from 'primeng/config';
+
+/**
+ * PrimeNG is required for components used inside `@dotcms/new-block-editor` (e.g. DataView in
+ * image/video dotCMS pickers). Theme + cssLayer order must match `apps/dotcms-block-editor/src/styles.css`.
+ */
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideBrowserGlobalErrorListeners(),
+ provideHttpClient(),
+ provideAnimationsAsync(),
+ providePrimeNG({
+ theme: {
+ preset: Lara,
+ options: {
+ darkModeSelector: '.dark',
+ cssLayer: {
+ name: 'primeng',
+ order: 'tailwind-base, primeng, tailwind-utilities'
+ }
+ }
+ }
+ })
+ ]
+};
diff --git a/core-web/apps/dotcms-block-editor/src/app/app.module.ts b/core-web/apps/dotcms-block-editor/src/app/app.module.ts
deleted file mode 100644
index 5db0bbd40f81..000000000000
--- a/core-web/apps/dotcms-block-editor/src/app/app.module.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { CommonModule } from '@angular/common';
-import { HttpClientModule } from '@angular/common/http';
-import { DoBootstrap, Injector, NgModule } from '@angular/core';
-import { createCustomElement } from '@angular/elements';
-import { FormsModule } from '@angular/forms';
-import { BrowserModule } from '@angular/platform-browser';
-import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-
-import { ListboxModule } from 'primeng/listbox';
-import { OrderListModule } from 'primeng/orderlist';
-
-import { BlockEditorModule, DotBlockEditorComponent } from '@dotcms/block-editor';
-import {
- DotPropertiesService,
- DotContentSearchService,
- DotMessageService
-} from '@dotcms/data-access';
-import { DotAssetSearchComponent, provideDotCMSTheme } from '@dotcms/ui';
-
-import { AppComponent } from './app.component';
-
-@NgModule({
- declarations: [AppComponent],
- imports: [
- BrowserModule,
- BrowserAnimationsModule,
- CommonModule,
- FormsModule,
- BlockEditorModule,
- OrderListModule,
- ListboxModule,
- HttpClientModule,
- DotAssetSearchComponent
- ],
- providers: [
- DotPropertiesService,
- DotContentSearchService,
- DotMessageService,
- provideDotCMSTheme()
- ]
-})
-export class AppModule implements DoBootstrap {
- constructor(private injector: Injector) {}
-
- ngDoBootstrap() {
- if (customElements.get('dotcms-block-editor') === undefined) {
- const element = createCustomElement(DotBlockEditorComponent, {
- injector: this.injector
- });
- customElements.define('dotcms-block-editor', element);
- }
- }
-}
diff --git a/core-web/apps/dotcms-block-editor/src/index.html b/core-web/apps/dotcms-block-editor/src/index.html
index dce10144bf60..4869c64eb278 100644
--- a/core-web/apps/dotcms-block-editor/src/index.html
+++ b/core-web/apps/dotcms-block-editor/src/index.html
@@ -3,9 +3,14 @@
DotBlockEditor
-
+
+
+
+
diff --git a/core-web/apps/dotcms-block-editor/src/main.ts b/core-web/apps/dotcms-block-editor/src/main.ts
index 207c6dde9399..6049f18db0b7 100644
--- a/core-web/apps/dotcms-block-editor/src/main.ts
+++ b/core-web/apps/dotcms-block-editor/src/main.ts
@@ -1,13 +1,12 @@
import { enableProdMode } from '@angular/core';
-import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+import { bootstrapApplication } from '@angular/platform-browser';
-import { AppModule } from './app/app.module';
+import { AppComponent } from './app/app.component';
+import { appConfig } from './app/app.config';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
-platformBrowserDynamic()
- .bootstrapModule(AppModule)
- .catch((err) => console.error(err));
+bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
diff --git a/core-web/apps/dotcms-block-editor/src/styles.css b/core-web/apps/dotcms-block-editor/src/styles.css
index 19318a93cbfd..f433b088eb1b 100644
--- a/core-web/apps/dotcms-block-editor/src/styles.css
+++ b/core-web/apps/dotcms-block-editor/src/styles.css
@@ -1,7 +1,219 @@
+/* You can add global styles to this file, and also import other style files */
+
+@layer tailwind-base, primeng, tailwind-utilities;
+
@import 'tailwindcss';
-@import 'tailwindcss-primeui';
+@plugin "@tailwindcss/typography";
+
+/* ─── Material Symbols (Outlined) ──────────────────────── */
+/* Font loaded from Google Fonts in index.html */
+.material-symbols-outlined {
+ font-family: 'Material Symbols Outlined';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 18px;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ user-select: none;
+ letter-spacing: normal;
+ text-transform: none;
+ white-space: nowrap;
+ word-wrap: normal;
+ direction: ltr;
+ font-feature-settings: 'liga';
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-variation-settings:
+ 'FILL' 0,
+ 'wght' 300,
+ 'GRAD' -25,
+ 'opsz' 20;
+}
+
+.tiptap {
+ @apply px-16 py-8;
+}
+
+/* ─── Drag handle ──────────────────────────────────────── */
+/* The extension manages show/hide via element.style.visibility — do NOT use opacity here */
+.drag-handle-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ z-index: 20;
+}
+
+.drag-handle,
+.add-block-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ color: #9ca3af;
+ transition:
+ color 150ms ease,
+ background-color 150ms ease;
+}
+
+.drag-handle {
+ cursor: grab;
+}
+
+.add-block-btn {
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ padding: 0;
+}
+
+.drag-handle:hover,
+.add-block-btn:hover {
+ color: #374151;
+ background-color: #f3f4f6;
+}
+
+.drag-handle:active {
+ cursor: grabbing;
+ background-color: #e5e7eb;
+}
+
+.add-block-btn:active {
+ background-color: #e5e7eb;
+}
+
+/* ─── Table ─────────────────────────────────────────────── */
+.tiptap table {
+ border-collapse: collapse;
+ width: 100%;
+ table-layout: fixed;
+ overflow: hidden;
+ margin: 0;
+}
+
+.tiptap td,
+.tiptap th {
+ border: 1px solid #e5e7eb;
+ padding: 8px 12px;
+ vertical-align: top;
+ position: relative;
+ min-width: 80px;
+}
+
+.tiptap th {
+ background-color: #f9fafb;
+ font-weight: 600;
+ text-align: left;
+}
+
+.tiptap .selectedCell {
+ background-color: #eff6ff;
+}
+
+.tiptap .column-resize-handle {
+ position: absolute;
+ right: -2px;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ background-color: #6366f1;
+ cursor: col-resize;
+ pointer-events: all;
+}
+
+.tiptap .tableWrapper {
+ overflow-x: auto;
+}
+
+/* ─── Placeholders ──────────────────────────────────────── */
+.tiptap p.is-empty::before,
+.tiptap h1.is-empty::before,
+.tiptap h2.is-empty::before,
+.tiptap h3.is-empty::before,
+.tiptap h4.is-empty::before,
+.tiptap blockquote p.is-empty::before {
+ content: attr(data-placeholder);
+ color: #9ca3af;
+ pointer-events: none;
+ float: left;
+ height: 0;
+}
+
+/* ─── Links ─────────────────────────────────────────────── */
+.tiptap a {
+ color: #6366f1;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.tiptap a:hover {
+ color: #4f46e5;
+}
+
+.tiptap a.link-editing {
+ background-color: rgba(99, 102, 241, 0.12);
+ border-radius: 2px;
+ outline: 2px solid rgba(99, 102, 241, 0.3);
+ outline-offset: 1px;
+}
+
+/* ─── Upload placeholder ─────────────────────────────── */
+.upload-placeholder {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+ border-radius: 0.5rem;
+ background-color: #f9fafb;
+ border: 1.5px dashed #d1d5db;
+ color: #6b7280;
+ margin: 0.25rem 0;
+ user-select: none;
+ cursor: default;
+}
+
+.upload-placeholder__icon {
+ font-size: 1.25rem;
+ color: #9ca3af;
+ flex-shrink: 0;
+}
+
+.upload-placeholder__label {
+ font-size: 0.875rem;
+ flex: 1;
+}
+
+.upload-placeholder__bar {
+ width: 100px;
+ height: 4px;
+ border-radius: 9999px;
+ background-color: #e5e7eb;
+ overflow: hidden;
+ flex-shrink: 0;
+ position: relative;
+}
+
+.upload-placeholder__bar::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-color: #6366f1;
+ border-radius: 9999px;
+ animation: upload-sweep 1.4s ease-in-out infinite;
+ transform-origin: left center;
+}
-.p-dialog-mask.p-component-overlay.p-dialog-mask-scrollblocker {
- background-color: transparent;
- backdrop-filter: none;
+@keyframes upload-sweep {
+ 0% {
+ transform: translateX(-100%) scaleX(0.4);
+ }
+ 50% {
+ transform: translateX(60%) scaleX(0.6);
+ }
+ 100% {
+ transform: translateX(200%) scaleX(0.4);
+ }
}
diff --git a/core-web/apps/dotcms-block-editor/tsconfig.app.json b/core-web/apps/dotcms-block-editor/tsconfig.app.json
index 9c2690370c4e..78e91a9a7b3b 100644
--- a/core-web/apps/dotcms-block-editor/tsconfig.app.json
+++ b/core-web/apps/dotcms-block-editor/tsconfig.app.json
@@ -5,7 +5,11 @@
"types": [],
"target": "ES2022",
"useDefineForClassFields": false,
- "moduleResolution": "bundler"
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "resolvePackageJsonExports": true,
+ "incremental": true,
+ "esModuleInterop": true
},
"files": ["src/main.ts", "src/polyfills.ts"],
"exclude": ["**/*.stories.ts", "**/*.stories.js"]
diff --git a/core-web/libs/dotcms-scss/angular/styles.scss b/core-web/libs/dotcms-scss/angular/styles.scss
index 455383dfebb0..c21c6800b0f0 100644
--- a/core-web/libs/dotcms-scss/angular/styles.scss
+++ b/core-web/libs/dotcms-scss/angular/styles.scss
@@ -131,14 +131,6 @@ However this is for the dragula we use in the angular components the one in the
user-select: none !important;
}
-code {
- color: $color-accessible-text-purple !important;
- background-color: $color-accessible-text-purple-bg;
- padding: $spacing-0 $spacing-1;
- font-family: $font-code;
- line-break: anywhere;
-}
-
.dot-mask {
background-color: transparent;
backdrop-filter: none;
diff --git a/core-web/libs/new-block-editor/.eslintrc.json b/core-web/libs/new-block-editor/.eslintrc.json
new file mode 100644
index 000000000000..66e6eb7f6d0c
--- /dev/null
+++ b/core-web/libs/new-block-editor/.eslintrc.json
@@ -0,0 +1,38 @@
+{
+ "extends": ["../../.eslintrc.base.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts"],
+ "extends": [
+ "plugin:@nx/angular",
+ "plugin:@angular-eslint/template/process-inline-templates"
+ ],
+ "rules": {
+ "@angular-eslint/directive-selector": [
+ "error",
+ {
+ "type": "attribute",
+ "prefix": "dot",
+ "style": "camelCase"
+ }
+ ],
+ "@angular-eslint/component-selector": [
+ "error",
+ {
+ "type": "element",
+ "prefix": "dot",
+ "style": "kebab-case"
+ }
+ ],
+ "@angular-eslint/prefer-standalone": "off",
+ "@angular-eslint/no-input-rename": "off"
+ }
+ },
+ {
+ "files": ["*.html"],
+ "extends": ["plugin:@nx/angular-template"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md
new file mode 100644
index 000000000000..2d0bb6952e34
--- /dev/null
+++ b/core-web/libs/new-block-editor/CLAUDE.md
@@ -0,0 +1,39 @@
+## Interaction Preferences
+
+Act with constructive skepticism. You are a collaborator with strong reasoning ability.
+
+Make decisions based on evidence. Do not assume you must agree with me.
+
+You should:
+
+- Question weak premises
+- Point out flaws in reasoning
+- Propose new approaches or mental models
+
+If I am approaching a problem from the wrong perspective or with incorrect assumptions, explain it clearly and suggest a better starting point.
+
+Be direct.
+Avoid unnecessary validation language, emojis, or marketing tone.
+
+## Expected Response Format
+
+Your responses should focus on:
+
+- **Core insight**
+- **Key tradeoffs**
+- **Major risks**
+- **Recommended next move**
+
+## Deferred Refactors
+
+### Floating dialog abstraction
+All three block dialogs (table, image, video) duplicate the same component-level logic:
+- `floatX`, `floatY`, `positioned` signals
+- `effect((onCleanup))` for document-level Escape + click-outside dismiss
+- `afterRenderEffect` with `computePosition(flip(), shift())` for positioning
+
+And the same service-level pattern:
+- `isOpen` + `clientRectFn` signals
+- `zone.run()` wrapping in `open()` / `close()`
+
+**Trigger:** Extract into a `FloatingPanelDirective` + generic base service when a 4th block type with a dialog is added, or when the duplication actively causes a bug/inconsistency. Not worth doing at 3 blocks.
\ No newline at end of file
diff --git a/core-web/libs/new-block-editor/jest.config.ts b/core-web/libs/new-block-editor/jest.config.ts
new file mode 100644
index 000000000000..01bf9067c12c
--- /dev/null
+++ b/core-web/libs/new-block-editor/jest.config.ts
@@ -0,0 +1,25 @@
+/* eslint-disable */
+export default {
+ displayName: 'new-block-editor',
+ preset: '../../jest.preset.js',
+ setupFilesAfterEnv: ['/src/test-setup.ts'],
+ globals: {},
+ transform: {
+ '^.+\\.(ts|mjs|js|html)$': [
+ 'jest-preset-angular',
+ {
+ tsconfig: '/tsconfig.spec.json',
+ stringifyContentPathRegex: '\\.(html|svg)$'
+ }
+ ]
+ },
+ transformIgnorePatterns: [
+ 'node_modules/(?!.*\\.mjs$|.*(y-protocols|lib0|y-prosemirror|@tiptap|marked))'
+ ],
+ snapshotSerializers: [
+ 'jest-preset-angular/build/serializers/no-ng-attributes',
+ 'jest-preset-angular/build/serializers/ng-snapshot',
+ 'jest-preset-angular/build/serializers/html-comment'
+ ],
+ coveragePathIgnorePatterns: ['node_modules/']
+};
diff --git a/core-web/libs/new-block-editor/project.json b/core-web/libs/new-block-editor/project.json
new file mode 100644
index 000000000000..ca9d09a6feff
--- /dev/null
+++ b/core-web/libs/new-block-editor/project.json
@@ -0,0 +1,23 @@
+{
+ "name": "new-block-editor",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "sourceRoot": "libs/new-block-editor/src",
+ "prefix": "dotcms",
+ "tags": ["type:feature", "scope:new-block-editor"],
+ "targets": {
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "libs/new-block-editor/jest.config.ts",
+ "verbose": false,
+ "tsConfig": "libs/new-block-editor/tsconfig.spec.json"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"]
+ }
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/feature.md b/core-web/libs/new-block-editor/src/feature.md
new file mode 100644
index 000000000000..d7fef684c3b3
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/feature.md
@@ -0,0 +1,12 @@
+### Features
+
+1. Convert the block editor component into a form-friendly component using `ControlValueAccessor`
+2. Add a **Grid block** that allows users to:
+ - Create columns
+ - Resize them
+
+ Two references for this behavior:
+ - Local: `/Users/rjvelazco/Desktop/dotcms/core/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/GridBlock.tsx`
+ - Remote: https://github.com/hunghg255/reactjs-tiptap-editor/tree/main/src/extensions/Column
+3. In the **Link form**, add a checkbox to let the user toggle whether the link should open in a new tab (`target="_blank"`)
+4. Add a **selected state style** for the following node types: images, videos, and contentlets
\ No newline at end of file
diff --git a/core-web/libs/new-block-editor/src/index.ts b/core-web/libs/new-block-editor/src/index.ts
new file mode 100644
index 000000000000..aac7aa758c6a
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/index.ts
@@ -0,0 +1,5 @@
+/**
+ * Experimental block editor playground — add feature exports from `lib/` as you refactor.
+ */
+
+export * from './lib/editor/editor.component';
diff --git a/core-web/libs/new-block-editor/src/lib/app.config.ts b/core-web/libs/new-block-editor/src/lib/app.config.ts
new file mode 100644
index 000000000000..052a99121608
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/app.config.ts
@@ -0,0 +1,29 @@
+import Lara from '@primeuix/themes/lara';
+
+import { provideHttpClient } from '@angular/common/http';
+import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
+import { provideRouter } from '@angular/router';
+
+import { providePrimeNG } from 'primeng/config';
+
+import { routes } from './app.routes';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideBrowserGlobalErrorListeners(),
+ provideHttpClient(),
+ provideRouter(routes),
+ providePrimeNG({
+ theme: {
+ preset: Lara,
+ options: {
+ darkModeSelector: '.dark',
+ cssLayer: {
+ name: 'primeng',
+ order: 'tailwind-base, primeng, tailwind-utilities'
+ }
+ }
+ }
+ })
+ ]
+};
diff --git a/core-web/libs/new-block-editor/src/lib/app.routes.ts b/core-web/libs/new-block-editor/src/lib/app.routes.ts
new file mode 100644
index 000000000000..dc39edb5f23a
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/app.routes.ts
@@ -0,0 +1,3 @@
+import { Routes } from '@angular/router';
+
+export const routes: Routes = [];
diff --git a/core-web/libs/new-block-editor/src/lib/app.spec.ts b/core-web/libs/new-block-editor/src/lib/app.spec.ts
new file mode 100644
index 000000000000..dd4e6efa7ac7
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/app.spec.ts
@@ -0,0 +1,24 @@
+import { TestBed } from '@angular/core/testing';
+
+import { App } from './app';
+
+describe('App', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [App]
+ }).compileComponents();
+ });
+
+ it('should create the app', () => {
+ const fixture = TestBed.createComponent(App);
+ const app = fixture.componentInstance;
+ expect(app).toBeTruthy();
+ });
+
+ it('should render title', async () => {
+ const fixture = TestBed.createComponent(App);
+ await fixture.whenStable();
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('h1')?.textContent).toContain('Hello, block-editor');
+ });
+});
diff --git a/core-web/libs/new-block-editor/src/lib/app.ts b/core-web/libs/new-block-editor/src/lib/app.ts
new file mode 100644
index 000000000000..51b15a5a65ad
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/app.ts
@@ -0,0 +1,13 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+
+import { EditorComponent } from './editor/editor.component';
+
+@Component({
+ selector: 'dot-block-editor-root',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [EditorComponent],
+ template: `
+
+ `
+})
+export class App {}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts b/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts
new file mode 100644
index 000000000000..04a2bad34bc2
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts
@@ -0,0 +1,28 @@
+import { NgZone, inject, signal } from '@angular/core';
+
+/**
+ * Shared open/close + signals for floating block insert dialogs.
+ * Subclasses own insert callbacks and any extra state (e.g. initialValues).
+ */
+export abstract class FloatingBlockDialogService {
+ protected readonly zone = inject(NgZone);
+
+ readonly isOpen = signal(false);
+ readonly clientRectFn = signal<(() => DOMRect | null) | null>(null);
+
+ protected openFloating(clientRectFn: () => DOMRect | null, arm: () => void): void {
+ this.zone.run(() => {
+ arm();
+ this.clientRectFn.set(clientRectFn);
+ this.isOpen.set(true);
+ });
+ }
+
+ protected closeFloating(disarm: () => void): void {
+ this.zone.run(() => {
+ disarm();
+ this.isOpen.set(false);
+ this.clientRectFn.set(null);
+ });
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts
new file mode 100644
index 000000000000..6bf70a8f3473
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts
@@ -0,0 +1,569 @@
+import { computePosition, flip, shift } from '@floating-ui/dom';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ afterRenderEffect,
+ computed,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview';
+
+import { take } from 'rxjs/operators';
+
+import { ImageDialogService } from './image-dialog.service';
+
+import {
+ DotCmsContentletService,
+ type DotCmsContentlet
+} from '../../services/dot-cms-contentlet.service';
+import { DotCmsUploadService } from '../../services/dot-cms-upload.service';
+import { DOT_CMS_BASE_URL } from '../../services/dot-cms.config';
+
+type Tab = 'upload' | 'url' | 'dotcms';
+
+@Component({
+ selector: 'dot-block-editor-image-dialog',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ReactiveFormsModule, DataViewModule],
+ host: {
+ '[attr.aria-label]': 'isEditing() ? "Edit image" : "Insert image"',
+ class: 'absolute z-50 w-[32rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
+ '[style.display]': 'service.isOpen() ? null : "none"',
+ '[style.visibility]': 'positioned() ? "visible" : "hidden"',
+ '[style.left.px]': 'floatX()',
+ '[style.top.px]': 'floatY()'
+ },
+ template: `
+ @if (isEditing()) {
+
+
+
+
+
+
+
+
+
+
+ Text shown when hovering over the image
+
+
+
+
+
+
+
+ Read aloud by screen readers; improves accessibility
+
+
+
+
+
+
+
+
+
+ } @else {
+
+
+
+
+
+
+
+ @if (activeTab() === 'upload') {
+
+ }
+
+ @if (activeTab() === 'url') {
+
+
+
+
+
+
+
+ }
+
+ @if (activeTab() === 'dotcms') {
+
+
+
+
+
+
+
+
+
+ @if (dotcmsError()) {
+
{{ dotcmsError() }}
+ } @else {
+
+
dotcmsRows"
+ [rows]="dotcmsRows"
+ [rowsPerPageOptions]="dotcmsRowsOptions"
+ [totalRecords]="dotcmsTotalRecords()"
+ [first]="dotcmsFirst()"
+ [pageLinks]="3"
+ paginatorPosition="bottom"
+ [showCurrentPageReport]="true"
+ currentPageReportTemplate="{first} – {last} of {totalRecords}"
+ layout="list"
+ emptyMessage="No images found."
+ [style]="{ border: 'none', boxShadow: 'none' }"
+ styleClass="!border-0 !shadow-none bg-transparent [&_.p-dataview-content]:border-0 [&_.p-dataview-content]:bg-transparent"
+ data-testid="dotcms-image-dataview"
+ (onLazyLoad)="onDotcmsLazyLoad($event)">
+
+
+ @for (img of items; track img.inode) {
+
+ }
+
+
+
+
+ }
+
+ }
+ }
+ `
+})
+export class ImageDialogComponent {
+ protected readonly service = inject(ImageDialogService);
+ private readonly el = inject(ElementRef);
+ private readonly zone = inject(NgZone);
+ private readonly document = inject(DOCUMENT);
+ private readonly dotCmsUpload = inject(DotCmsUploadService);
+ private readonly dotCmsContentlet = inject(DotCmsContentletService);
+
+ protected readonly floatX = signal(0);
+ protected readonly floatY = signal(0);
+ protected readonly positioned = signal(false);
+ protected readonly activeTab = signal('url');
+ protected readonly isEditing = computed(() => this.service.initialValues() !== null);
+ protected readonly uploading = signal(false);
+ protected readonly dotcmsImages = signal([]);
+ protected readonly dotcmsLoading = signal(false);
+ protected readonly dotcmsError = signal(null);
+ protected readonly dotcmsTotalRecords = signal(0);
+ protected readonly dotcmsFirst = signal(0);
+ /** Last page size from DataView (rows per page); kept for “Search” reset. */
+ protected readonly dotcmsPageSize = signal(8);
+ readonly dotcmsRows = 8;
+ readonly dotcmsRowsOptions: number[] = [8, 16, 24];
+
+ private previouslyFocused: HTMLElement | null = null;
+
+ readonly urlControl = new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)]
+ });
+
+ readonly dotcmsSearchControl = new FormControl('', { nonNullable: true });
+
+ readonly editForm = new FormGroup({
+ src: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
+ title: new FormControl('', { nonNullable: true }),
+ alt: new FormControl('', { nonNullable: true })
+ });
+
+ constructor() {
+ effect(() => {
+ const values = this.service.initialValues();
+ if (values) {
+ untracked(() =>
+ this.editForm.setValue({
+ src: values.src,
+ title: values.title,
+ alt: values.alt
+ })
+ );
+ }
+ });
+
+ effect((onCleanup) => {
+ if (!this.service.isOpen()) return;
+
+ this.previouslyFocused = this.document.activeElement as HTMLElement | null;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') this.zone.run(() => this.service.close());
+ };
+ const handleMouseDown = (event: MouseEvent) => {
+ if (!this.el.nativeElement.contains(event.target as Node)) {
+ this.zone.run(() => this.service.close());
+ }
+ };
+
+ this.document.addEventListener('keydown', handleKeyDown);
+ this.document.addEventListener('mousedown', handleMouseDown);
+ onCleanup(() => {
+ this.document.removeEventListener('keydown', handleKeyDown);
+ this.document.removeEventListener('mousedown', handleMouseDown);
+ this.previouslyFocused?.focus({ preventScroll: true });
+ this.previouslyFocused = null;
+ });
+ });
+
+ afterRenderEffect(() => {
+ const isOpen = this.service.isOpen();
+ const clientRectFn = this.service.clientRectFn();
+
+ if (!isOpen || !clientRectFn) {
+ untracked(() => {
+ this.positioned.set(false);
+ this.activeTab.set('url');
+ this.urlControl.reset('');
+ this.dotcmsSearchControl.reset('');
+ this.dotcmsImages.set([]);
+ this.dotcmsError.set(null);
+ this.dotcmsLoading.set(false);
+ this.dotcmsTotalRecords.set(0);
+ this.dotcmsFirst.set(0);
+ this.dotcmsPageSize.set(this.dotcmsRows);
+ this.editForm.reset({ src: '', title: '', alt: '' });
+ });
+ return;
+ }
+
+ const virtualRef = {
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect()
+ };
+
+ computePosition(virtualRef, this.el.nativeElement, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [flip(), shift({ padding: 8 })]
+ }).then(({ x, y }) => {
+ this.zone.run(() => {
+ untracked(() => {
+ this.floatX.set(x);
+ this.floatY.set(y);
+ this.positioned.set(true);
+ });
+ });
+ setTimeout(() => {
+ const firstInput = this.el.nativeElement.querySelector(
+ 'input:not([type="file"]):not([type="checkbox"])'
+ ) as HTMLElement | null;
+ firstInput?.focus();
+ }, 0);
+ });
+ });
+ }
+
+ tabClass(tab: Tab): string {
+ const base =
+ 'flex min-w-0 flex-1 items-center justify-center gap-1.5 px-2 py-2.5 text-xs font-medium border-b-2 transition-colors sm:gap-2 sm:px-3 sm:text-sm';
+ return this.activeTab() === tab
+ ? `${base} border-indigo-500 text-indigo-600 bg-white`
+ : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`;
+ }
+
+ dotcmsThumbUrl(inode: string): string {
+ return `${DOT_CMS_BASE_URL}/dA/${inode}/120/max`;
+ }
+
+ onSelectDotcmsTab(): void {
+ this.activeTab.set('dotcms');
+ }
+
+ onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void {
+ this.dotcmsPageSize.set(event.rows);
+ this.fetchDotcmsImagesPage(event.first, event.rows);
+ }
+
+ /** New search/filter: reset to first page (keeps current rows-per-page). */
+ runDotcmsSearch(): void {
+ this.dotcmsFirst.set(0);
+ this.fetchDotcmsImagesPage(0, this.dotcmsPageSize());
+ }
+
+ private fetchDotcmsImagesPage(first: number, rows: number): void {
+ this.dotcmsLoading.set(true);
+ this.dotcmsError.set(null);
+ this.dotCmsContentlet
+ .searchImages({
+ text: this.dotcmsSearchControl.getRawValue(),
+ offset: first,
+ limit: rows
+ })
+ .pipe(take(1))
+ .subscribe({
+ next: ({ contentlets, totalRecords }) => {
+ this.zone.run(() => {
+ this.dotcmsImages.set(contentlets);
+ this.dotcmsTotalRecords.set(totalRecords);
+ this.dotcmsFirst.set(first);
+ this.dotcmsLoading.set(false);
+ });
+ },
+ error: () => {
+ this.zone.run(() => {
+ this.dotcmsImages.set([]);
+ this.dotcmsTotalRecords.set(0);
+ this.dotcmsError.set('Could not load images from dotCMS.');
+ this.dotcmsLoading.set(false);
+ });
+ }
+ });
+ }
+
+ insertFromDotcms(contentlet: DotCmsContentlet): void {
+ const src = `${DOT_CMS_BASE_URL}/dA/${contentlet.inode}`;
+ const label = contentlet.title || contentlet.identifier;
+ this.service.insert(src, label || undefined, label || undefined);
+ }
+
+ async onFileChange(event: Event): Promise {
+ const file = (event.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+
+ this.uploading.set(true);
+ try {
+ const src = await this.dotCmsUpload.uploadImage(file);
+ this.zone.run(() => this.service.insert(src, undefined, file.name));
+ } catch (err) {
+ console.error('Image upload failed', err);
+ } finally {
+ this.uploading.set(false);
+ }
+ }
+
+ onInsertUrl(): void {
+ if (this.urlControl.invalid) return;
+ this.service.insert(this.urlControl.getRawValue());
+ }
+
+ onApplyEdit(): void {
+ if (this.editForm.controls.src.invalid) return;
+ const { src, title, alt } = this.editForm.getRawValue();
+ this.service.insert(src, title || undefined, alt || undefined);
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts
new file mode 100644
index 000000000000..a6cbdf3fea80
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts
@@ -0,0 +1,41 @@
+import { Injectable, signal } from '@angular/core';
+
+import { FloatingBlockDialogService } from '../floating-block-dialog.base';
+
+export type InsertImageFn = (src: string, title?: string, alt?: string) => void;
+
+export interface ImageInitialValues {
+ src: string;
+ title: string;
+ alt: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ImageDialogService extends FloatingBlockDialogService {
+ readonly initialValues = signal(null);
+
+ private insertFn: InsertImageFn | null = null;
+
+ open(
+ insertFn: InsertImageFn,
+ clientRectFn: () => DOMRect | null,
+ initialValues?: ImageInitialValues
+ ): void {
+ this.openFloating(clientRectFn, () => {
+ this.insertFn = insertFn;
+ this.initialValues.set(initialValues ?? null);
+ });
+ }
+
+ insert(src: string, title?: string, alt?: string): void {
+ this.insertFn?.(src, title, alt);
+ this.close();
+ }
+
+ close(): void {
+ this.closeFloating(() => {
+ this.initialValues.set(null);
+ this.insertFn = null;
+ });
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts
new file mode 100644
index 000000000000..26a3594e370e
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts
@@ -0,0 +1,195 @@
+import { computePosition, flip, shift } from '@floating-ui/dom';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ afterRenderEffect,
+ computed,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { LinkDialogService } from './link-dialog.service';
+
+@Component({
+ selector: 'dot-block-editor-link-dialog',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ReactiveFormsModule],
+ host: {
+ '[attr.aria-label]': 'isEditing() ? "Edit link" : "Insert link"',
+ class: 'absolute z-50 w-80 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
+ '[style.display]': 'service.isOpen() ? null : "none"',
+ '[style.visibility]': 'positioned() ? "visible" : "hidden"',
+ '[style.left.px]': 'floatX()',
+ '[style.top.px]': 'floatY()'
+ },
+ template: `
+
+
+ {{ isEditing() ? 'Edit Link' : 'Insert Link' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+})
+export class LinkDialogComponent {
+ protected readonly service = inject(LinkDialogService);
+ private readonly el = inject(ElementRef);
+ private readonly zone = inject(NgZone);
+ private readonly document = inject(DOCUMENT);
+
+ protected readonly floatX = signal(0);
+ protected readonly floatY = signal(0);
+ protected readonly positioned = signal(false);
+ protected readonly isEditing = computed(() => this.service.initialValues() !== null);
+
+ private previouslyFocused: HTMLElement | null = null;
+
+ readonly form = new FormGroup({
+ href: new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)]
+ }),
+ displayText: new FormControl('', { nonNullable: true }),
+ openInNewTab: new FormControl(false, { nonNullable: true })
+ });
+
+ constructor() {
+ // Pre-populate form when opened in edit mode
+ effect(() => {
+ const values = this.service.initialValues();
+ untracked(() => {
+ if (values) {
+ this.form.setValue({
+ href: values.href,
+ displayText: values.displayText,
+ openInNewTab: values.target === '_blank'
+ });
+ }
+ });
+ });
+
+ effect((onCleanup) => {
+ if (!this.service.isOpen()) return;
+
+ this.previouslyFocused = this.document.activeElement as HTMLElement | null;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') this.zone.run(() => this.service.close());
+ };
+ const handleMouseDown = (event: MouseEvent) => {
+ if (!this.el.nativeElement.contains(event.target as Node)) {
+ this.zone.run(() => this.service.close());
+ }
+ };
+
+ this.document.addEventListener('keydown', handleKeyDown);
+ this.document.addEventListener('mousedown', handleMouseDown);
+ onCleanup(() => {
+ this.document.removeEventListener('keydown', handleKeyDown);
+ this.document.removeEventListener('mousedown', handleMouseDown);
+ this.previouslyFocused?.focus({ preventScroll: true });
+ this.previouslyFocused = null;
+ });
+ });
+
+ afterRenderEffect(() => {
+ const isOpen = this.service.isOpen();
+ const clientRectFn = this.service.clientRectFn();
+
+ if (!isOpen || !clientRectFn) {
+ untracked(() => {
+ this.positioned.set(false);
+ this.form.reset({ href: '', displayText: '', openInNewTab: false });
+ });
+ return;
+ }
+
+ const virtualRef = {
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect()
+ };
+
+ computePosition(virtualRef, this.el.nativeElement, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [flip(), shift({ padding: 8 })]
+ }).then(({ x, y }) => {
+ this.zone.run(() => {
+ untracked(() => {
+ this.floatX.set(x);
+ this.floatY.set(y);
+ this.positioned.set(true);
+ });
+ });
+ setTimeout(() => {
+ const firstInput = this.el.nativeElement.querySelector(
+ 'input:not([type="file"]):not([type="checkbox"])'
+ ) as HTMLElement | null;
+ firstInput?.focus();
+ }, 0);
+ });
+ });
+ }
+
+ onInsert(): void {
+ if (this.form.controls.href.invalid) return;
+ const { href, displayText, openInNewTab } = this.form.getRawValue();
+ this.service.insert(href, displayText.trim() || undefined, openInNewTab);
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts
new file mode 100644
index 000000000000..acb9b5b6377a
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts
@@ -0,0 +1,55 @@
+import { Injectable, signal } from '@angular/core';
+
+import { FloatingBlockDialogService } from '../floating-block-dialog.base';
+
+export type InsertLinkFn = (href: string, displayText?: string, openInNewTab?: boolean) => void;
+
+export interface LinkInitialValues {
+ href: string;
+ displayText: string;
+ target?: string | null;
+}
+
+@Injectable({ providedIn: 'root' })
+export class LinkDialogService extends FloatingBlockDialogService {
+ readonly initialValues = signal(null);
+
+ private insertFn: InsertLinkFn | null = null;
+ private activeLinkEl: HTMLElement | null = null;
+
+ open(
+ insertFn: InsertLinkFn,
+ clientRectFn: () => DOMRect | null,
+ initialValues?: { href?: string; displayText?: string; target?: string | null },
+ linkEl?: HTMLElement
+ ): void {
+ this.openFloating(clientRectFn, () => {
+ this.insertFn = insertFn;
+ this.initialValues.set(
+ initialValues
+ ? {
+ href: initialValues.href ?? '',
+ displayText: initialValues.displayText ?? '',
+ target: initialValues.target ?? null
+ }
+ : null
+ );
+ this.activeLinkEl = linkEl ?? null;
+ this.activeLinkEl?.classList.add('link-editing');
+ });
+ }
+
+ insert(href: string, displayText?: string, openInNewTab?: boolean): void {
+ this.insertFn?.(href, displayText, openInNewTab);
+ this.close();
+ }
+
+ close(): void {
+ this.closeFloating(() => {
+ this.activeLinkEl?.classList.remove('link-editing');
+ this.activeLinkEl = null;
+ this.initialValues.set(null);
+ this.insertFn = null;
+ });
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts
new file mode 100644
index 000000000000..26a1ea9c4ec3
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts
@@ -0,0 +1,191 @@
+import { computePosition, flip, shift } from '@floating-ui/dom';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ afterRenderEffect,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { TableDialogService } from './table-dialog.service';
+
+const DEFAULT_ROWS = 3;
+const DEFAULT_COLS = 3;
+const MAX_VALUE = 20;
+
+@Component({
+ selector: 'dot-block-editor-table-dialog',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ReactiveFormsModule],
+ host: {
+ 'aria-label': 'Insert table',
+ class: 'absolute z-50 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
+ '[style.display]': 'service.isOpen() ? null : "none"',
+ '[style.visibility]': 'positioned() ? "visible" : "hidden"',
+ '[style.left.px]': 'floatX()',
+ '[style.top.px]': 'floatY()'
+ },
+ template: `
+
+ `
+})
+export class TableDialogComponent {
+ protected readonly service = inject(TableDialogService);
+ private readonly el = inject(ElementRef);
+ private readonly zone = inject(NgZone);
+ private readonly document = inject(DOCUMENT);
+
+ protected readonly floatX = signal(0);
+ protected readonly floatY = signal(0);
+ protected readonly positioned = signal(false);
+ protected readonly maxValue = MAX_VALUE;
+
+ private previouslyFocused: HTMLElement | null = null;
+
+ readonly form = new FormGroup({
+ rows: new FormControl(DEFAULT_ROWS, {
+ nonNullable: true,
+ validators: [Validators.required, Validators.min(1), Validators.max(MAX_VALUE)]
+ }),
+ cols: new FormControl(DEFAULT_COLS, {
+ nonNullable: true,
+ validators: [Validators.required, Validators.min(1), Validators.max(MAX_VALUE)]
+ }),
+ withHeaderRow: new FormControl(true, { nonNullable: true })
+ });
+
+ constructor() {
+ effect((onCleanup) => {
+ if (!this.service.isOpen()) return;
+
+ this.previouslyFocused = this.document.activeElement as HTMLElement | null;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') this.zone.run(() => this.service.close());
+ };
+ const handleMouseDown = (event: MouseEvent) => {
+ if (!this.el.nativeElement.contains(event.target as Node)) {
+ this.zone.run(() => this.service.close());
+ }
+ };
+
+ this.document.addEventListener('keydown', handleKeyDown);
+ this.document.addEventListener('mousedown', handleMouseDown);
+ onCleanup(() => {
+ this.document.removeEventListener('keydown', handleKeyDown);
+ this.document.removeEventListener('mousedown', handleMouseDown);
+ this.previouslyFocused?.focus({ preventScroll: true });
+ this.previouslyFocused = null;
+ });
+ });
+
+ afterRenderEffect(() => {
+ const isOpen = this.service.isOpen();
+ const clientRectFn = this.service.clientRectFn();
+
+ if (!isOpen || !clientRectFn) {
+ untracked(() => {
+ this.positioned.set(false);
+ this.form.reset({
+ rows: DEFAULT_ROWS,
+ cols: DEFAULT_COLS,
+ withHeaderRow: true
+ });
+ });
+ return;
+ }
+
+ const virtualRef = {
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect()
+ };
+
+ computePosition(virtualRef, this.el.nativeElement, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [flip(), shift({ padding: 8 })]
+ }).then(({ x, y }) => {
+ this.zone.run(() => {
+ untracked(() => {
+ this.floatX.set(x);
+ this.floatY.set(y);
+ this.positioned.set(true);
+ });
+ });
+ setTimeout(() => {
+ const firstInput = this.el.nativeElement.querySelector(
+ 'input:not([type="file"]):not([type="checkbox"])'
+ ) as HTMLElement | null;
+ firstInput?.focus();
+ }, 0);
+ });
+ });
+ }
+
+ onApply(): void {
+ if (this.form.invalid) return;
+ this.service.insert(this.form.getRawValue());
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts
new file mode 100644
index 000000000000..a96b88bb0414
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts
@@ -0,0 +1,32 @@
+import { Injectable } from '@angular/core';
+
+import { FloatingBlockDialogService } from '../floating-block-dialog.base';
+
+export interface TableConfig {
+ rows: number;
+ cols: number;
+ withHeaderRow: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TableDialogService extends FloatingBlockDialogService {
+ private insertFn: ((config: TableConfig) => void) | null = null;
+
+ open(insertFn: (config: TableConfig) => void, clientRectFn: () => DOMRect | null): void {
+ this.openFloating(clientRectFn, () => {
+ this.insertFn = insertFn;
+ });
+ }
+
+ /** Commits the table dimensions and closes the dialog (same contract as other block dialogs’ `insert`). */
+ insert(config: TableConfig): void {
+ this.insertFn?.(config);
+ this.close();
+ }
+
+ close(): void {
+ this.closeFloating(() => {
+ this.insertFn = null;
+ });
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts
new file mode 100644
index 000000000000..48327325c672
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts
@@ -0,0 +1,484 @@
+import { computePosition, flip, shift } from '@floating-ui/dom';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ afterRenderEffect,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview';
+
+import { take } from 'rxjs/operators';
+
+import { VideoDialogService } from './video-dialog.service';
+
+import {
+ DotCmsContentletService,
+ type DotCmsContentlet
+} from '../../services/dot-cms-contentlet.service';
+import { DotCmsUploadService } from '../../services/dot-cms-upload.service';
+import { DOT_CMS_BASE_URL } from '../../services/dot-cms.config';
+
+type Tab = 'upload' | 'url' | 'dotcms';
+
+@Component({
+ selector: 'dot-block-editor-video-dialog',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ReactiveFormsModule, DataViewModule],
+ host: {
+ 'aria-label': 'Insert video',
+ class: 'absolute z-50 w-[32rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
+ '[style.display]': 'service.isOpen() ? null : "none"',
+ '[style.visibility]': 'positioned() ? "visible" : "hidden"',
+ '[style.left.px]': 'floatX()',
+ '[style.top.px]': 'floatY()'
+ },
+ template: `
+
+
+
+
+
+
+
+
+ @if (activeTab() === 'upload') {
+
+ }
+
+
+ @if (activeTab() === 'url') {
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+ @if (activeTab() === 'dotcms') {
+
+
+
+
+
+
+
+
+
+ @if (dotcmsError()) {
+
{{ dotcmsError() }}
+ } @else {
+
+
dotcmsRows"
+ [rows]="dotcmsRows"
+ [rowsPerPageOptions]="dotcmsRowsOptions"
+ [totalRecords]="dotcmsTotalRecords()"
+ [first]="dotcmsFirst()"
+ [pageLinks]="3"
+ paginatorPosition="bottom"
+ [showCurrentPageReport]="true"
+ currentPageReportTemplate="{first} – {last} of {totalRecords}"
+ layout="list"
+ emptyMessage="No videos found."
+ [style]="{ border: 'none', boxShadow: 'none' }"
+ styleClass="!border-0 !shadow-none bg-transparent [&_.p-dataview-content]:border-0 [&_.p-dataview-content]:bg-transparent"
+ data-testid="dotcms-video-dataview"
+ (onLazyLoad)="onDotcmsLazyLoad($event)">
+
+
+ @for (vid of items; track vid.inode) {
+
+ }
+
+
+
+
+ }
+
+ }
+ `
+})
+export class VideoDialogComponent {
+ protected readonly service = inject(VideoDialogService);
+ private readonly el = inject(ElementRef);
+ private readonly zone = inject(NgZone);
+ private readonly document = inject(DOCUMENT);
+ private readonly dotCmsUpload = inject(DotCmsUploadService);
+ private readonly dotCmsContentlet = inject(DotCmsContentletService);
+
+ protected readonly floatX = signal(0);
+ protected readonly floatY = signal(0);
+ protected readonly positioned = signal(false);
+ protected readonly activeTab = signal('url');
+ protected readonly uploading = signal(false);
+ protected readonly dotcmsVideos = signal([]);
+ protected readonly dotcmsLoading = signal(false);
+ protected readonly dotcmsError = signal(null);
+ protected readonly dotcmsTotalRecords = signal(0);
+ protected readonly dotcmsFirst = signal(0);
+ protected readonly dotcmsPageSize = signal(8);
+ readonly dotcmsRows = 8;
+ readonly dotcmsRowsOptions: number[] = [8, 16, 24];
+
+ private previouslyFocused: HTMLElement | null = null;
+
+ readonly urlControl = new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)]
+ });
+
+ readonly titleControl = new FormControl('', { nonNullable: true });
+
+ readonly dotcmsSearchControl = new FormControl('', { nonNullable: true });
+
+ constructor() {
+ effect((onCleanup) => {
+ if (!this.service.isOpen()) return;
+
+ this.previouslyFocused = this.document.activeElement as HTMLElement | null;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') this.zone.run(() => this.service.close());
+ };
+ const handleMouseDown = (event: MouseEvent) => {
+ if (!this.el.nativeElement.contains(event.target as Node)) {
+ this.zone.run(() => this.service.close());
+ }
+ };
+
+ this.document.addEventListener('keydown', handleKeyDown);
+ this.document.addEventListener('mousedown', handleMouseDown);
+ onCleanup(() => {
+ this.document.removeEventListener('keydown', handleKeyDown);
+ this.document.removeEventListener('mousedown', handleMouseDown);
+ this.previouslyFocused?.focus({ preventScroll: true });
+ this.previouslyFocused = null;
+ });
+ });
+
+ afterRenderEffect(() => {
+ const isOpen = this.service.isOpen();
+ const clientRectFn = this.service.clientRectFn();
+
+ if (!isOpen || !clientRectFn) {
+ untracked(() => {
+ this.positioned.set(false);
+ this.activeTab.set('url');
+ this.urlControl.reset('');
+ this.titleControl.reset('');
+ this.dotcmsSearchControl.reset('');
+ this.dotcmsVideos.set([]);
+ this.dotcmsError.set(null);
+ this.dotcmsLoading.set(false);
+ this.dotcmsTotalRecords.set(0);
+ this.dotcmsFirst.set(0);
+ this.dotcmsPageSize.set(this.dotcmsRows);
+ });
+ return;
+ }
+
+ const virtualRef = {
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect()
+ };
+
+ computePosition(virtualRef, this.el.nativeElement, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [flip(), shift({ padding: 8 })]
+ }).then(({ x, y }) => {
+ this.zone.run(() => {
+ untracked(() => {
+ this.floatX.set(x);
+ this.floatY.set(y);
+ this.positioned.set(true);
+ });
+ });
+ setTimeout(() => {
+ const firstInput = this.el.nativeElement.querySelector(
+ 'input:not([type="file"]):not([type="checkbox"])'
+ ) as HTMLElement | null;
+ firstInput?.focus();
+ }, 0);
+ });
+ });
+ }
+
+ tabClass(tab: Tab): string {
+ const base =
+ 'flex min-w-0 flex-1 items-center justify-center gap-1.5 px-2 py-2.5 text-xs font-medium border-b-2 transition-colors sm:gap-2 sm:px-3 sm:text-sm';
+ return this.activeTab() === tab
+ ? `${base} border-indigo-500 text-indigo-600 bg-white`
+ : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`;
+ }
+
+ /** Full asset URL; avoid image resize filters — they may not apply to video binaries. */
+ dotcmsVideoPreviewUrl(inode: string): string {
+ return `${DOT_CMS_BASE_URL}/dA/${inode}`;
+ }
+
+ onSelectDotcmsTab(): void {
+ this.activeTab.set('dotcms');
+ }
+
+ onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void {
+ this.dotcmsPageSize.set(event.rows);
+ this.fetchDotcmsVideosPage(event.first, event.rows);
+ }
+
+ runDotcmsSearch(): void {
+ this.dotcmsFirst.set(0);
+ this.fetchDotcmsVideosPage(0, this.dotcmsPageSize());
+ }
+
+ private fetchDotcmsVideosPage(first: number, rows: number): void {
+ this.dotcmsLoading.set(true);
+ this.dotcmsError.set(null);
+ this.dotCmsContentlet
+ .searchVideos({
+ text: this.dotcmsSearchControl.getRawValue(),
+ offset: first,
+ limit: rows
+ })
+ .pipe(take(1))
+ .subscribe({
+ next: ({ contentlets, totalRecords }) => {
+ this.zone.run(() => {
+ this.dotcmsVideos.set(contentlets);
+ this.dotcmsTotalRecords.set(totalRecords);
+ this.dotcmsFirst.set(first);
+ this.dotcmsLoading.set(false);
+ });
+ },
+ error: () => {
+ this.zone.run(() => {
+ this.dotcmsVideos.set([]);
+ this.dotcmsTotalRecords.set(0);
+ this.dotcmsError.set('Could not load videos from dotCMS.');
+ this.dotcmsLoading.set(false);
+ });
+ }
+ });
+ }
+
+ insertFromDotcms(contentlet: DotCmsContentlet): void {
+ const src = `${DOT_CMS_BASE_URL}/dA/${contentlet.inode}`;
+ const title = contentlet.title || contentlet.identifier || undefined;
+ this.service.insert(src, title);
+ }
+
+ async onFileChange(event: Event): Promise {
+ const file = (event.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+
+ this.uploading.set(true);
+ try {
+ const src = await this.dotCmsUpload.uploadVideo(file);
+ const title = file.name.replace(/\.[^.]+$/, '');
+ this.zone.run(() => this.service.insert(src, title));
+ } catch (err) {
+ console.error('Video upload failed', err);
+ } finally {
+ this.uploading.set(false);
+ }
+ }
+
+ onInsertUrl(): void {
+ if (this.urlControl.invalid) return;
+ const title = this.titleControl.getRawValue().trim() || undefined;
+ this.service.insert(this.urlControl.getRawValue(), title);
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts
new file mode 100644
index 000000000000..c152f365ebae
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts
@@ -0,0 +1,27 @@
+import { Injectable } from '@angular/core';
+
+import { FloatingBlockDialogService } from '../floating-block-dialog.base';
+
+export type InsertVideoFn = (src: string, title?: string) => void;
+
+@Injectable({ providedIn: 'root' })
+export class VideoDialogService extends FloatingBlockDialogService {
+ private insertFn: InsertVideoFn | null = null;
+
+ open(insertFn: InsertVideoFn, clientRectFn: () => DOMRect | null): void {
+ this.openFloating(clientRectFn, () => {
+ this.insertFn = insertFn;
+ });
+ }
+
+ insert(src: string, title?: string): void {
+ this.insertFn?.(src, title);
+ this.close();
+ }
+
+ close(): void {
+ this.closeFloating(() => {
+ this.insertFn = null;
+ });
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts b/core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts
new file mode 100644
index 000000000000..eb8db0ea58f6
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts
@@ -0,0 +1,24 @@
+import type { WritableSignal } from '@angular/core';
+
+import type { Editor } from '@tiptap/core';
+
+export interface CharacterStatsSignals {
+ wordCount: WritableSignal;
+ charCount: WritableSignal;
+ readingTime: WritableSignal;
+}
+
+/** Reads TipTap CharacterCount extension storage and updates UI signals. */
+export function syncCharacterStatsFromEditor(editor: Editor, signals: CharacterStatsSignals): void {
+ const storage = editor.storage as {
+ characterCount?: { words: () => number; characters: () => number };
+ };
+ const cc = storage.characterCount;
+ if (!cc) return;
+
+ const words = cc.words();
+ const chars = cc.characters();
+ signals.wordCount.set(words);
+ signals.charCount.set(chars);
+ signals.readingTime.set(Math.max(1, Math.ceil(words / 200)));
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts b/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts
new file mode 100644
index 000000000000..32092f7fb3a2
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts
@@ -0,0 +1,86 @@
+import type { Editor } from '@tiptap/core';
+
+import type { ImageDialogService } from './components/image/image-dialog.service';
+import type { LinkDialogService } from './components/link/link-dialog.service';
+
+/**
+ * Handles clicks on rich content inside ProseMirror (image / link edit dialogs).
+ * Kept outside the component to keep EditorComponent focused on lifecycle and wiring.
+ */
+export function handleEditorProseMirrorClick(
+ event: MouseEvent,
+ editor: Editor,
+ _imageDialog: ImageDialogService,
+ linkDialog: LinkDialogService
+): void {
+ // TODO: Image click-to-edit disabled — use the toolbar "Edit image properties" button instead.
+ // const img = (event.target as HTMLElement).closest('img') as HTMLImageElement | null;
+ // if (img) {
+ // const src = img.getAttribute('src') ?? '';
+ // const title = img.getAttribute('title') ?? '';
+ // const alt = img.getAttribute('alt') ?? '';
+ // const rect = img.getBoundingClientRect();
+ //
+ // let imgPos: number;
+ // try {
+ // imgPos = editor.view.posAtDOM(img, 0);
+ // } catch {
+ // return;
+ // }
+ //
+ // event.preventDefault();
+ //
+ // imageDialog.open(
+ // (newSrc, newTitle, newAlt) => {
+ // editor
+ // .chain()
+ // .focus()
+ // .setNodeSelection(imgPos)
+ // .updateAttributes('image', {
+ // src: newSrc,
+ // title: newTitle || null,
+ // alt: newAlt || null
+ // })
+ // .run();
+ // },
+ // () => rect,
+ // { src, title, alt }
+ // );
+ // return;
+ // }
+
+ const anchor = (event.target as HTMLElement).closest('a[href]');
+ if (!anchor) return;
+
+ const href = anchor.getAttribute('href') ?? '';
+ const displayText = anchor.textContent?.trim() ?? '';
+ const rect = anchor.getBoundingClientRect();
+
+ let anchorPos: number;
+ try {
+ anchorPos = editor.view.posAtDOM(anchor, 0);
+ } catch {
+ anchorPos = editor.state.selection.from;
+ }
+
+ event.preventDefault();
+
+ linkDialog.open(
+ (newHref, newDisplayText) => {
+ editor
+ .chain()
+ .focus()
+ .setTextSelection(anchorPos)
+ .extendMarkRange('link')
+ .insertContent({
+ type: 'text',
+ text: newDisplayText ?? newHref,
+ marks: [{ type: 'link', attrs: { href: newHref } }]
+ })
+ .run();
+ },
+ () => rect,
+ { href, displayText },
+ anchor as HTMLElement
+ );
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts b/core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts
new file mode 100644
index 000000000000..395f571c43f6
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts
@@ -0,0 +1,41 @@
+/** Default HTML shown when the editor loads (demo / onboarding). */
+export const EDITOR_DEMO_CONTENT = `
+ Block Editor
+ Welcome! Type / anywhere to open the block menu and insert content.
+
+ Text blocks
+ Regular paragraph text. You can write bold, italic, and inline code.
+ A blockquote stands out from the rest of the content — great for callouts or citations.
+ const greet = (name: string) => \`Hello, \${name}!\`;
+console.log(greet('World'));
+
+ Lists
+
+ - Bullet item one
+ - Bullet item two
+ - Bullet item three
+
+
+ - First ordered item
+ - Second ordered item
+ - Third ordered item
+
+
+ Links
+ Click once to select a link, double-click to edit it. Try it: Tiptap docs or Angular docs. You can also paste a URL directly and it will auto-link.
+
+ Table
+
+
+ | Feature | Status | Notes |
+
+
+ | Slash menu | ✅ Done | Type / to trigger |
+ | Drag & drop | ✅ Done | Grab the handle on the left |
+ | Tables | ✅ Done | Resizable columns |
+ | Links | ✅ Done | Autolink + dialog |
+ | Images | ✅ Done | URL or file upload |
+ | Video | ✅ Done | URL or file upload |
+
+
+ `;
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts
new file mode 100644
index 000000000000..feff936b02f2
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts
@@ -0,0 +1,349 @@
+import { TiptapEditorDirective } from 'ngx-tiptap';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ OnDestroy,
+ computed,
+ effect,
+ forwardRef,
+ inject,
+ input,
+ signal
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+
+import { Editor } from '@tiptap/core';
+
+import { ImageDialogComponent } from './components/image/image-dialog.component';
+import { ImageDialogService } from './components/image/image-dialog.service';
+import { LinkDialogComponent } from './components/link/link-dialog.component';
+import { LinkDialogService } from './components/link/link-dialog.service';
+import { TableDialogComponent } from './components/table/table-dialog.component';
+import { TableDialogService } from './components/table/table-dialog.service';
+import { VideoDialogComponent } from './components/video/video-dialog.component';
+import { VideoDialogService } from './components/video/video-dialog.service';
+import { syncCharacterStatsFromEditor } from './editor-character-stats';
+import { handleEditorProseMirrorClick } from './editor-chrome-click';
+import { handleMediaDrop } from './editor.utils';
+import { EmojiPickerComponent } from './emoji-menu/emoji-picker.component';
+import { createEditorExtensions } from './extensions/editor-extensions';
+import { DotCmsUploadService } from './services/dot-cms-upload.service';
+import { SlashMenuComponent } from './slash-menu/slash-menu.component';
+import { SlashMenuService } from './slash-menu/slash-menu.service';
+import { ToolbarComponent } from './toolbar/toolbar.component';
+
+@Component({
+ selector: 'dot-block-editor',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => EditorComponent),
+ multi: true
+ }
+ ],
+ imports: [
+ TiptapEditorDirective,
+ SlashMenuComponent,
+ EmojiPickerComponent,
+ TableDialogComponent,
+ ImageDialogComponent,
+ VideoDialogComponent,
+ LinkDialogComponent,
+ ToolbarComponent
+ ],
+ template: `
+
+
+
+
+
+
+ {{ wordCount() }} {{ wordCount() === 1 ? 'word' : 'words' }}
+
+ {{ charCount() }} {{ charCount() === 1 ? 'character' : 'characters' }}
+
+ {{ readingTime() }} min read
+
+
+
+
+
+
+
+
+
+
+ `,
+ styles: `
+ :host ::ng-deep .ProseMirror {
+ outline: none;
+ min-height: 200px;
+ }
+
+ :host ::ng-deep .ProseMirror figure {
+ display: block;
+ margin: 0;
+ }
+
+ :host ::ng-deep .ProseMirror figure.image-wrap-left {
+ float: left;
+ width: 50%;
+ margin: 0 1rem 1rem 0;
+ }
+
+ :host ::ng-deep .ProseMirror figure.image-wrap-right {
+ float: right;
+ width: 50%;
+ margin: 0 0 1rem 1rem;
+ }
+
+ :host ::ng-deep .ProseMirror figure img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ }
+
+ /* Selected node ring */
+ :host ::ng-deep .ProseMirror figure.is-selected img,
+ :host ::ng-deep .ProseMirror video.is-selected,
+ :host ::ng-deep .ProseMirror [data-type='dot-contentlet'].is-selected {
+ outline: 2px solid #6366f1;
+ outline-offset: 2px;
+ border-radius: 2px;
+ }
+
+ /* Grid block — fr-based columns, position:relative for resize handle overlay */
+ :host ::ng-deep .ProseMirror .grid-block {
+ display: grid;
+ gap: 1rem;
+ margin: 1rem 0;
+ position: relative;
+ }
+ /* display:contents lets gridColumn cells participate in the parent CSS Grid */
+ :host ::ng-deep .ProseMirror .grid-block__grid {
+ display: contents;
+ }
+ :host ::ng-deep .ProseMirror .grid-block__column {
+ min-width: 0;
+ }
+ :host ::ng-deep .ProseMirror .grid-block__column-content {
+ padding: 0.5rem;
+ border: 1px dashed #d1d5db;
+ border-radius: 0.375rem;
+ min-height: 3rem;
+ }
+ :host ::ng-deep .ProseMirror .grid-block__column-content:focus-within {
+ border-color: #a5b4fc;
+ background: color-mix(in srgb, #6366f1 8%, transparent);
+ }
+
+ /* Resize handle */
+ :host ::ng-deep .grid-block__resize-handle {
+ position: absolute;
+ width: 0.75rem;
+ cursor: col-resize;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ :host ::ng-deep .grid-block__resize-handle::after {
+ content: '';
+ width: 1px;
+ height: 2rem;
+ background: #9ca3af;
+ border-radius: 9999px;
+ transition:
+ background 0.15s,
+ height 0.15s;
+ }
+ :host ::ng-deep .grid-block__resize-handle:hover::after {
+ background: #6366f1;
+ height: 100%;
+ }
+ :host ::ng-deep .grid-block__resize-handle--active::after {
+ background: #6366f1;
+ height: 100%;
+ transition: none;
+ }
+
+ /* Drag preview overlay */
+ :host ::ng-deep .grid-block__drag-preview {
+ position: absolute;
+ display: flex;
+ z-index: 5;
+ pointer-events: none;
+ border-radius: 0.5rem;
+ }
+ :host ::ng-deep .grid-block__drag-preview-col {
+ border-radius: 0.5rem;
+ border: 2px dashed #818cf8;
+ background: color-mix(in srgb, #6366f1 8%, transparent);
+ }
+ `
+})
+export class EditorComponent implements OnDestroy, ControlValueAccessor {
+ protected readonly menuService = inject(SlashMenuService);
+ private readonly linkDialogService = inject(LinkDialogService);
+ private readonly imageDialogService = inject(ImageDialogService);
+ private readonly videoDialogService = inject(VideoDialogService);
+ private readonly tableDialogService = inject(TableDialogService);
+ private readonly dotCmsUpload = inject(DotCmsUploadService);
+ private readonly document = inject(DOCUMENT);
+
+ readonly allowedBlocks = input();
+
+ readonly wordCount = signal(0);
+ readonly charCount = signal(0);
+ readonly readingTime = signal(0);
+
+ private readonly stats = {
+ wordCount: this.wordCount,
+ charCount: this.charCount,
+ readingTime: this.readingTime
+ };
+
+ readonly editor: Editor = new Editor({
+ onCreate: ({ editor }) => syncCharacterStatsFromEditor(editor, this.stats),
+ onUpdate: ({ editor }) => {
+ syncCharacterStatsFromEditor(editor, this.stats);
+ this.onChange(editor.getHTML());
+ },
+ onBlur: () => {
+ this.onTouched();
+ },
+ editorProps: {
+ handleDrop: (view, event, slice, moved) =>
+ handleMediaDrop(
+ this.editor,
+ view,
+ event as DragEvent,
+ slice,
+ moved,
+ (file) => this.dotCmsUpload.uploadImage(file),
+ (file) => this.dotCmsUpload.uploadVideo(file)
+ )
+ },
+ extensions: createEditorExtensions(this.menuService, this.allowedBlocks()),
+ content: ''
+ });
+
+ // ── Fullscreen (F3) ──────────────────────────────────────────────────────
+
+ readonly isFullscreen = signal(false);
+
+ protected toggleFullscreen(): void {
+ this.isFullscreen.update((v) => !v);
+ }
+
+ protected readonly wrapperClass = computed(() =>
+ this.isFullscreen()
+ ? 'fixed inset-0 z-[9998] flex items-center justify-center bg-black/50'
+ : ''
+ );
+
+ protected readonly panelClass = computed(() =>
+ this.isFullscreen()
+ ? 'relative flex flex-col w-[90vw] h-[90vh] rounded-lg border border-gray-200 bg-white overflow-hidden'
+ : 'relative mx-auto mt-8 max-w-3xl rounded-lg border border-gray-200'
+ );
+
+ constructor() {
+ // F2: Sync allowedBlocks to slash menu service
+ effect(() => {
+ this.menuService.allowedBlocks.set(this.allowedBlocks() ?? null);
+ });
+
+ // F3: Escape key + scroll lock for fullscreen
+ effect((onCleanup) => {
+ if (!this.isFullscreen()) return;
+ this.document.body.style.overflow = 'hidden';
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ const anyDialogOpen =
+ this.imageDialogService.isOpen() ||
+ this.linkDialogService.isOpen() ||
+ this.videoDialogService.isOpen() ||
+ this.tableDialogService.isOpen() ||
+ this.menuService.isOpen();
+ if (!anyDialogOpen) this.isFullscreen.set(false);
+ };
+
+ this.document.addEventListener('keydown', onKey);
+ onCleanup(() => {
+ this.document.removeEventListener('keydown', onKey);
+ this.document.body.style.overflow = '';
+ });
+ });
+ }
+
+ onClick(event: MouseEvent): void {
+ handleEditorProseMirrorClick(
+ event,
+ this.editor,
+ this.imageDialogService,
+ this.linkDialogService
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.document.body.style.overflow = '';
+ this.editor.destroy();
+ }
+
+ private onChange: (value: string) => void = (_value: string) => {
+ // Implementation provided by registerOnChange
+ };
+ private onTouched: () => void = () => {
+ // Implementation provided by registerOnTouched
+ };
+
+ writeValue(content: string | null): void {
+ const html = content ?? '';
+ if (html !== this.editor.getHTML()) {
+ this.editor.commands.setContent(html, { emitUpdate: false });
+ }
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.editor.setEditable(!isDisabled);
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.utils.ts b/core-web/libs/new-block-editor/src/lib/editor/editor.utils.ts
new file mode 100644
index 000000000000..f7ca327b5831
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor.utils.ts
@@ -0,0 +1,93 @@
+import { Editor } from '@tiptap/core';
+import { Slice } from '@tiptap/pm/model';
+import { EditorView } from '@tiptap/pm/view';
+
+import {
+ insertUploadPlaceholders,
+ replacePlaceholder,
+ removePlaceholder
+} from './extensions/upload-placeholder.extension';
+
+export function handleMediaDrop(
+ editor: Editor,
+ view: EditorView,
+ event: DragEvent,
+ _slice: Slice,
+ moved: boolean,
+ uploadImage?: (file: File) => Promise,
+ uploadVideo?: (file: File) => Promise
+): boolean {
+ if (moved) return false;
+
+ const allFiles = Array.from(event.dataTransfer?.files ?? []);
+ const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
+ const videoFiles = allFiles.filter((f) => f.type.startsWith('video/'));
+
+ if (!imageFiles.length && !videoFiles.length) return false;
+
+ event.preventDefault();
+ const dropResult = view.posAtCoords({ left: event.clientX, top: event.clientY });
+ const pos = dropResult?.pos ?? view.state.selection.from;
+
+ // Build placeholder descriptors for all files
+ const imagePlaceholders = imageFiles.map((_, i) => ({
+ id: `img-${Date.now()}-${i}`,
+ mediaType: 'image' as const
+ }));
+ const videoPlaceholders = videoFiles.map((_, i) => ({
+ id: `vid-${Date.now()}-${i}`,
+ mediaType: 'video' as const
+ }));
+
+ // Insert all placeholders in one transaction — gives immediate feedback
+ insertUploadPlaceholders(editor, pos, [...imagePlaceholders, ...videoPlaceholders]);
+
+ // ── Images ──────────────────────────────────────────────────────────────
+ imageFiles.forEach((file, i) => {
+ const { id } = imagePlaceholders[i];
+
+ if (uploadImage) {
+ uploadImage(file)
+ .then((src) =>
+ replacePlaceholder(editor, id, {
+ type: 'image',
+ attrs: { src, alt: file.name }
+ })
+ )
+ .catch((err) => {
+ console.error('Image drop upload failed', err);
+ removePlaceholder(editor, id);
+ });
+ } else {
+ const reader = new FileReader();
+ reader.onload = () => {
+ replacePlaceholder(editor, id, {
+ type: 'image',
+ attrs: { src: reader.result as string, alt: file.name }
+ });
+ };
+ reader.readAsDataURL(file);
+ }
+ });
+
+ // ── Videos ──────────────────────────────────────────────────────────────
+ videoFiles.forEach((file, i) => {
+ const { id } = videoPlaceholders[i];
+
+ if (uploadVideo) {
+ uploadVideo(file)
+ .then((src) => {
+ const title = file.name.replace(/\.[^.]+$/, '');
+ replacePlaceholder(editor, id, { type: 'video', attrs: { src, title } });
+ })
+ .catch((err) => {
+ console.error('Video drop upload failed', err);
+ removePlaceholder(editor, id);
+ });
+ } else {
+ removePlaceholder(editor, id);
+ }
+ });
+
+ return true;
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.component.ts b/core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.component.ts
new file mode 100644
index 000000000000..cfdac8f0f645
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.component.ts
@@ -0,0 +1,117 @@
+import { computePosition, flip, shift } from '@floating-ui/dom';
+
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ CUSTOM_ELEMENTS_SCHEMA,
+ afterNextRender,
+ afterRenderEffect,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
+
+import { EmojiPickerService } from './emoji-picker.service';
+
+@Component({
+ selector: 'dot-block-editor-emoji-picker',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ imports: [],
+ host: {
+ class: 'absolute z-50',
+ '[style.display]': 'service.isOpen() ? null : "none"',
+ '[style.visibility]': 'positioned() ? "visible" : "hidden"',
+ '[style.left.px]': 'floatX()',
+ '[style.top.px]': 'floatY()'
+ },
+ template: ``
+})
+export class EmojiPickerComponent {
+ protected readonly service = inject(EmojiPickerService);
+ private readonly el = inject(ElementRef);
+ private readonly zone = inject(NgZone);
+
+ protected readonly floatX = signal(0);
+ protected readonly floatY = signal(0);
+ protected readonly positioned = signal(false);
+
+ constructor() {
+ // Mount the emoji-mart web component once after the host element is in the DOM
+ afterNextRender(() => {
+ import('emoji-mart').then(({ Picker }) => {
+ import('@emoji-mart/data').then(({ default: data }) => {
+ const picker = new Picker({
+ data,
+ theme: 'light',
+ previewPosition: 'none',
+ onEmojiSelect: (emoji: { native: string }) => {
+ this.zone.run(() => {
+ this.service.insert(emoji.native);
+ this.service.close();
+ });
+ }
+ });
+ this.el.nativeElement.appendChild(picker as unknown as Node);
+ });
+ });
+ });
+
+ // Re-position whenever the picker opens or the reference rect changes
+ afterRenderEffect(() => {
+ const isOpen = this.service.isOpen();
+ const clientRectFn = this.service.clientRectFn();
+
+ if (!isOpen || !clientRectFn) {
+ untracked(() => this.positioned.set(false));
+ return;
+ }
+
+ const virtualRef = {
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect()
+ };
+
+ computePosition(virtualRef, this.el.nativeElement, {
+ placement: 'bottom-start',
+ strategy: 'absolute',
+ middleware: [flip(), shift({ padding: 8 })]
+ }).then(({ x, y }) => {
+ this.zone.run(() => {
+ untracked(() => {
+ this.floatX.set(x);
+ this.floatY.set(y);
+ this.positioned.set(true);
+ });
+ });
+ });
+ });
+
+ // Close on Escape or click outside
+ effect((onCleanup) => {
+ if (!this.service.isOpen()) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ this.zone.run(() => this.service.close());
+ }
+ };
+
+ const handleClick = (e: MouseEvent) => {
+ if (!this.el.nativeElement.contains(e.target as Node)) {
+ this.zone.run(() => this.service.close());
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ document.addEventListener('mousedown', handleClick);
+
+ onCleanup(() => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.removeEventListener('mousedown', handleClick);
+ });
+ });
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.service.ts b/core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.service.ts
new file mode 100644
index 000000000000..f061fc13607b
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.service.ts
@@ -0,0 +1,31 @@
+import { Injectable, NgZone, inject, signal } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class EmojiPickerService {
+ private readonly zone = inject(NgZone);
+
+ readonly isOpen = signal(false);
+ readonly clientRectFn = signal<(() => DOMRect | null) | null>(null);
+
+ private insertFn: ((emoji: string) => void) | null = null;
+
+ open(insertFn: (emoji: string) => void, clientRectFn: () => DOMRect | null): void {
+ this.zone.run(() => {
+ this.insertFn = insertFn;
+ this.clientRectFn.set(clientRectFn);
+ this.isOpen.set(true);
+ });
+ }
+
+ close(): void {
+ this.zone.run(() => {
+ this.isOpen.set(false);
+ this.clientRectFn.set(null);
+ this.insertFn = null;
+ });
+ }
+
+ insert(emoji: string): void {
+ this.insertFn?.(emoji);
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts
new file mode 100644
index 000000000000..e0a9a9787fed
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts
@@ -0,0 +1,263 @@
+import { shift } from '@floating-ui/dom';
+
+import type { Editor } from '@tiptap/core';
+import { DragHandle, defaultComputePositionConfig } from '@tiptap/extension-drag-handle';
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
+
+/**
+ * Shared mutable state for the block gutter (drag grip + add button), updated on each hover.
+ *
+ * @property editor - Active TipTap editor, or `null` before first `onNodeChange`.
+ * @property pos - Document position of the hovered block; `-1` when none.
+ * @property nodeSize - `node.nodeSize` for the hovered block (used to insert below).
+ */
+type GutterState = {
+ editor: Editor | null;
+ pos: number;
+ nodeSize: number;
+};
+
+/**
+ * Payload passed to `onNodeChange` by `@tiptap/extension-drag-handle`.
+ * `pos` is present at runtime but missing from the package typings.
+ */
+type DragHandleNodeChangePayload = {
+ editor: Editor;
+ node: ProseMirrorNode | null;
+ pos?: number;
+};
+
+/**
+ * Finds the last valid text cursor position inside a block (end of inline content),
+ * or the start of an empty textblock.
+ *
+ * @param doc - Current ProseMirror document.
+ * @param blockPos - Document position of the block node.
+ * @returns A valid caret position inside the block, or `null` if none applies.
+ */
+function endPosInsideBlock(doc: ProseMirrorNode, blockPos: number): number | null {
+ const block = doc.nodeAt(blockPos);
+ if (!block) return null;
+ if (block.isTextblock) return blockPos + block.nodeSize - 1;
+ if (block.childCount === 0) return null;
+
+ let childPos = blockPos + 1;
+ for (let i = 0; i < block.childCount - 1; i++) {
+ childPos += block.child(i).nodeSize;
+ }
+ return endPosInsideBlock(doc, childPos);
+}
+
+/** SVG markup for the six-dot drag grip (injected into the draggable handle). */
+const DRAG_GRIP_SVG = ``;
+
+/** SVG markup for the “add block” (+) control. */
+const ADD_ICON_SVG = ``;
+
+/**
+ * Builds the draggable grip element TipTap attaches drag listeners to.
+ *
+ * @returns The `.drag-handle` root element (single child of the gutter wrapper).
+ */
+function createDragGripElement(): HTMLElement {
+ const dragEl = document.createElement('div');
+ dragEl.className = 'drag-handle';
+ dragEl.setAttribute('aria-hidden', 'true');
+ dragEl.style.cursor = 'grab';
+ dragEl.innerHTML = DRAG_GRIP_SVG;
+ return dragEl;
+}
+
+/**
+ * Handles primary-button down on the “+” control: opens the slash menu on an empty line,
+ * or inserts a new paragraph below the block and opens slash when the block has text.
+ *
+ * @param state - Gutter state (must hold current `editor`, `pos`, `nodeSize`).
+ * @param event - `mousedown` from the add button (default prevented to avoid focus quirks).
+ */
+function onAddBlockButtonMouseDown(state: GutterState, event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const { editor, pos, nodeSize } = state;
+ if (!editor || pos < 0) return;
+
+ const { doc } = editor.state;
+ const outer = doc.nodeAt(pos);
+ const hasText = outer !== null && outer.textContent.trim().length > 0;
+
+ if (!hasText) {
+ const endInside = endPosInsideBlock(doc, pos);
+ if (endInside !== null) {
+ editor.chain().focus().setTextSelection(endInside).insertContent('/').run();
+ return;
+ }
+ }
+
+ const insertPos = pos + nodeSize;
+ editor
+ .chain()
+ .focus()
+ .insertContentAt(insertPos, { type: 'paragraph' })
+ .setTextSelection(insertPos + 1)
+ .insertContent('/')
+ .run();
+}
+
+/**
+ * Creates the non-draggable “+” button and wires slash / new-paragraph behavior.
+ *
+ * @param state - Shared gutter state (read on each click).
+ * @returns Configured `.add-block-btn` element.
+ */
+function createAddBlockButton(state: GutterState): HTMLElement {
+ const addBtn = document.createElement('button');
+ addBtn.type = 'button';
+ addBtn.className = 'add-block-btn';
+ addBtn.setAttribute('aria-label', 'Add block below, or open block menu on an empty line');
+ addBtn.setAttribute('draggable', 'false');
+ addBtn.innerHTML = ADD_ICON_SVG;
+ addBtn.addEventListener('dragstart', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ addBtn.addEventListener('mousedown', (e) => onAddBlockButtonMouseDown(state, e));
+ return addBtn;
+}
+
+/**
+ * Root element returned by DragHandle `render()`: wrapper + grip + add button.
+ *
+ * @param state - Shared gutter state passed through to the add button handler.
+ * @returns `.drag-handle-wrapper` element (visibility toggled by the extension).
+ */
+function createGutterRoot(state: GutterState): HTMLElement {
+ const root = document.createElement('div');
+ root.className = 'drag-handle-wrapper';
+ root.style.visibility = 'hidden';
+ root.appendChild(createDragGripElement());
+ root.appendChild(createAddBlockButton(state));
+ return root;
+}
+
+/**
+ * Returns a `dragstart` listener that corrects `setDragImage` horizontal offset.
+ *
+ * TipTap uses `event.clientX - wrapperRect.left` where the clone’s `wrapperRect` is near the
+ * viewport left edge; for editors that are horizontally offset (e.g. centered), the ghost
+ * appears shifted. This handler runs on the editor parent in the **bubble** phase after
+ * TipTap’s listener and recomputes offset from the real block’s `getBoundingClientRect()`.
+ *
+ * @param state - Gutter state; uses `editor` and `pos` to resolve the block DOM node.
+ * @param getIsDragHandleDrag - Whether the current drag started from our handle (ignore other drags).
+ * @returns Listener to attach once on `editor.view.dom.parentElement`.
+ */
+function createFixDragImageOffsetHandler(
+ state: GutterState,
+ getIsDragHandleDrag: () => boolean
+): (e: DragEvent) => void {
+ return (e: DragEvent) => {
+ if (!getIsDragHandleDrag() || state.pos < 0 || !state.editor || !e.dataTransfer) return;
+ const blockEl = state.editor.view.nodeDOM(state.pos) as HTMLElement | null;
+ if (!blockEl) return;
+ const blockRect = blockEl.getBoundingClientRect();
+ const offsetX = Math.max(0, e.clientX - blockRect.left);
+ e.dataTransfer.setDragImage(blockEl, offsetX, 0);
+ };
+}
+
+/**
+ * Attaches `listener` to the editor container’s parent for `dragstart`, at most once.
+ * Bubble order ensures our fix runs after TipTap’s handle `dragstart`.
+ *
+ * @param editor - TipTap editor (uses `view.dom.parentElement`).
+ * @param listener - Typically {@link createFixDragImageOffsetHandler}'s return value.
+ * @param registered - Mutable flag; set to `true` after the first successful add.
+ */
+function ensureParentDragStartListener(
+ editor: Editor | undefined,
+ listener: (e: DragEvent) => void,
+ registered: { current: boolean }
+): void {
+ if (registered.current || !editor?.view.dom.parentElement) return;
+ editor.view.dom.parentElement.addEventListener('dragstart', listener);
+ registered.current = true;
+}
+
+/**
+ * Syncs gutter state from the drag-handle plugin and ensures the drag-image fix listener is registered.
+ *
+ * @param payload - `onNodeChange` argument from the extension.
+ * @param state - Mutable gutter state to update.
+ * @param fixDragImageOffset - Drag-image correction listener.
+ * @param listenerRegistered - Tracks whether the parent `dragstart` listener was added.
+ */
+function handleNodeChange(
+ payload: DragHandleNodeChangePayload,
+ state: GutterState,
+ fixDragImageOffset: (e: DragEvent) => void,
+ listenerRegistered: { current: boolean }
+): void {
+ const { editor, node } = payload;
+ const pos = payload.pos;
+ state.editor = editor;
+ state.pos = pos ?? -1;
+ state.nodeSize = node?.nodeSize ?? 0;
+
+ ensureParentDragStartListener(editor, fixDragImageOffset, listenerRegistered);
+}
+
+/**
+ * Configures TipTap’s {@link DragHandle} with a two-part gutter: draggable grip + “+” button.
+ *
+ * - One wrapper from `render()`; TipTap attaches drag behavior to that root’s draggable child.
+ * - Grip is first so the add control stays inside the padded gutter and is not clipped.
+ * - Add button is `draggable="false"` and stops `dragstart` so it never starts a block drag.
+ * - Floating UI `shift` keeps the gutter on-screen; default TipTap position config is spread in.
+ *
+ * @returns A configured `DragHandle` extension ready for `Editor` extensions array.
+ */
+export function createBlockGutterDragHandle() {
+ const state: GutterState = {
+ editor: null,
+ pos: -1,
+ nodeSize: 0
+ };
+
+ let isDragHandleDrag = false;
+ const listenerRegistered = { current: false };
+
+ const fixDragImageOffset = createFixDragImageOffsetHandler(state, () => isDragHandleDrag);
+
+ return DragHandle.configure({
+ computePositionConfig: {
+ ...defaultComputePositionConfig,
+ middleware: [shift({ padding: 8 })]
+ },
+ onNodeChange: (payload) =>
+ handleNodeChange(
+ payload as DragHandleNodeChangePayload,
+ state,
+ fixDragImageOffset,
+ listenerRegistered
+ ),
+ onElementDragStart: () => {
+ isDragHandleDrag = true;
+ document.documentElement.style.setProperty('cursor', 'grabbing', 'important');
+ },
+ onElementDragEnd: () => {
+ isDragHandleDrag = false;
+ document.documentElement.style.removeProperty('cursor');
+ },
+ render: () => createGutterRoot(state)
+ });
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/contentlet.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/contentlet.extension.ts
new file mode 100644
index 000000000000..ff095dce782e
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/contentlet.extension.ts
@@ -0,0 +1,219 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import type { DOMOutputSpec } from '@tiptap/pm/model';
+
+/** TipTap node name for embedded dotCMS contentlets (slash menu → content type → contentlet). */
+export const DOT_CONTENTLET_NODE_NAME = 'dotContentlet' as const;
+
+export const DotContentlet = Node.create({
+ name: DOT_CONTENTLET_NODE_NAME,
+ group: 'block',
+ atom: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ inode: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('data-inode'),
+ renderHTML: (attrs) =>
+ attrs.inode != null && attrs.inode !== ''
+ ? { 'data-inode': String(attrs.inode) }
+ : {}
+ },
+ identifier: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('data-identifier'),
+ renderHTML: (attrs) =>
+ attrs.identifier != null && attrs.identifier !== ''
+ ? { 'data-identifier': String(attrs.identifier) }
+ : {}
+ },
+ title: {
+ default: '',
+ parseHTML: (element) => element.getAttribute('data-title') ?? '',
+ renderHTML: (attrs) =>
+ attrs.title != null && attrs.title !== ''
+ ? { 'data-title': String(attrs.title) }
+ : {}
+ },
+ contentType: {
+ default: '',
+ parseHTML: (element) => element.getAttribute('data-content-type') ?? '',
+ renderHTML: (attrs) =>
+ attrs.contentType != null && attrs.contentType !== ''
+ ? { 'data-content-type': String(attrs.contentType) }
+ : {}
+ },
+ modDate: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('data-mod-date'),
+ renderHTML: (attrs) =>
+ attrs.modDate != null && attrs.modDate !== ''
+ ? { 'data-mod-date': String(attrs.modDate) }
+ : {}
+ }
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="dot-contentlet"]' }];
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const { identifier, title, contentType, modDate } = node.attrs as Record<
+ string,
+ unknown
+ >;
+
+ const displayTitle =
+ (typeof title === 'string' && title) ||
+ (typeof identifier === 'string' && identifier) ||
+ 'Contentlet';
+
+ const dom = document.createElement('div');
+ dom.setAttribute('data-type', 'dot-contentlet');
+ dom.classList.add(
+ 'not-prose',
+ 'my-4',
+ 'rounded-lg',
+ 'border',
+ 'border-gray-200',
+ 'bg-gray-50',
+ 'p-4',
+ 'shadow-sm',
+ 'dark:border-gray-700',
+ 'dark:bg-gray-900/40'
+ );
+
+ if (node.attrs.inode != null && node.attrs.inode !== '') {
+ dom.setAttribute('data-inode', String(node.attrs.inode));
+ }
+
+ if (identifier != null && identifier !== '') {
+ dom.setAttribute('data-identifier', String(identifier));
+ }
+
+ if (title != null && title !== '') {
+ dom.setAttribute('data-title', String(title));
+ }
+
+ if (contentType != null && contentType !== '') {
+ dom.setAttribute('data-content-type', String(contentType));
+ }
+
+ if (modDate != null && modDate !== '') {
+ dom.setAttribute('data-mod-date', String(modDate));
+ }
+
+ // Content type badge
+ const badge = document.createElement('span');
+ badge.classList.add(
+ 'mb-2',
+ 'inline-flex',
+ 'max-w-full',
+ 'items-center',
+ 'rounded-full',
+ 'bg-indigo-100',
+ 'px-2.5',
+ 'py-0.5',
+ 'text-xs',
+ 'font-medium',
+ 'text-indigo-800',
+ 'dark:bg-indigo-900/50',
+ 'dark:text-indigo-200'
+ );
+ badge.textContent = String(contentType || 'Content');
+ dom.appendChild(badge);
+
+ // Title paragraph
+ const titleEl = document.createElement('p');
+ titleEl.classList.add(
+ 'text-base',
+ 'font-semibold',
+ 'text-gray-900',
+ 'dark:text-gray-100'
+ );
+ titleEl.textContent = displayTitle;
+ dom.appendChild(titleEl);
+
+ // Identifier paragraph
+ const idEl = document.createElement('p');
+ idEl.classList.add(
+ 'mt-1',
+ 'font-mono',
+ 'text-xs',
+ 'text-gray-500',
+ 'dark:text-gray-400'
+ );
+ idEl.textContent = String(identifier ?? '');
+ dom.appendChild(idEl);
+
+ // Updated date paragraph (conditional)
+ if (modDate) {
+ const dateEl = document.createElement('p');
+ dateEl.classList.add('mt-2', 'text-xs', 'text-gray-400', 'dark:text-gray-500');
+ dateEl.textContent = `Updated ${String(modDate)}`;
+ dom.appendChild(dateEl);
+ }
+
+ return {
+ dom,
+ selectNode() {
+ dom.classList.add('is-selected');
+ },
+ deselectNode() {
+ dom.classList.remove('is-selected');
+ }
+ };
+ };
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ const { identifier, title, contentType, modDate } = node.attrs;
+ const displayTitle =
+ (typeof title === 'string' && title) ||
+ (typeof identifier === 'string' && identifier) ||
+ 'Contentlet';
+
+ const children: DOMOutputSpec[] = [
+ [
+ 'span',
+ {
+ class: 'mb-2 inline-flex max-w-full items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200'
+ },
+ String(contentType || 'Content')
+ ],
+ [
+ 'p',
+ { class: 'text-base font-semibold text-gray-900 dark:text-gray-100' },
+ String(displayTitle)
+ ],
+ [
+ 'p',
+ { class: 'mt-1 font-mono text-xs text-gray-500 dark:text-gray-400' },
+ String(identifier ?? '')
+ ]
+ ];
+
+ if (modDate) {
+ children.push([
+ 'p',
+ { class: 'mt-2 text-xs text-gray-400 dark:text-gray-500' },
+ `Updated ${String(modDate)}`
+ ]);
+ }
+
+ return [
+ 'div',
+ mergeAttributes(
+ {
+ 'data-type': 'dot-contentlet',
+ class: 'not-prose my-4 rounded-lg border border-gray-200 bg-gray-50 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/40'
+ },
+ HTMLAttributes
+ ),
+ ...children
+ ];
+ }
+});
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts
new file mode 100644
index 000000000000..231ab879b2a9
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts
@@ -0,0 +1,98 @@
+import type { Extensions } from '@tiptap/core';
+import CharacterCount from '@tiptap/extension-character-count';
+import Emoji, { emojis } from '@tiptap/extension-emoji';
+import Link from '@tiptap/extension-link';
+import Placeholder from '@tiptap/extension-placeholder';
+import { TableKit } from '@tiptap/extension-table';
+import StarterKit from '@tiptap/starter-kit';
+
+import { createBlockGutterDragHandle } from './block-gutter.extension';
+import { DotContentlet } from './contentlet.extension';
+import { GridBlock, GridColumn } from './grid.extension';
+import { DotImage } from './image.extension';
+import { createSlashCommandExtension } from './slash-command.extension';
+import { UploadPlaceholderExtension } from './upload-placeholder.extension';
+import { Video } from './video.extension';
+
+import type { SlashMenuService } from '../slash-menu/slash-menu.service';
+
+export function createEditorExtensions(
+ menuService: SlashMenuService,
+ allowedBlocks?: string[]
+): Extensions {
+ const has = (name: string): boolean => !allowedBlocks || allowedBlocks.includes(name);
+
+ return [
+ StarterKit.configure({
+ dropcursor: {
+ color: '#6366f1',
+ width: 2
+ },
+ heading: has('heading') ? {} : false,
+ bulletList: has('bulletList') ? {} : false,
+ orderedList: has('orderedList') ? {} : false,
+ blockquote: has('blockquote') ? {} : false,
+ codeBlock: has('codeBlock') ? {} : false,
+ horizontalRule: has('horizontalRule') ? {} : false
+ }),
+ createBlockGutterDragHandle(),
+ CharacterCount,
+ ...(has('table') ? [TableKit] : []),
+ ...(has('image') ? [DotImage] : []),
+ ...(has('link')
+ ? [
+ Link.configure({
+ openOnClick: false,
+ enableClickSelection: true,
+ autolink: true,
+ linkOnPaste: true,
+ HTMLAttributes: {
+ rel: 'noopener noreferrer',
+ target: '_self'
+ }
+ })
+ ]
+ : []),
+ ...(has('video') ? [Video] : []),
+ ...(has('contentlet') ? [DotContentlet] : []),
+ ...(has('gridBlock') ? [GridBlock, GridColumn] : []),
+ UploadPlaceholderExtension,
+ ...(has('emoji')
+ ? [
+ Emoji.configure({
+ emojis,
+ enableEmoticons: true,
+ suggestion: {
+ char: ':',
+ items: () => [],
+ render: () => ({
+ onStart: () => undefined,
+ onUpdate: () => undefined,
+ onKeyDown: () => false,
+ onExit: () => undefined
+ })
+ }
+ })
+ ]
+ : []),
+ createSlashCommandExtension(menuService),
+ Placeholder.configure({
+ showOnlyCurrent: true,
+ placeholder: ({ node, editor }) => {
+ if (editor.isEmpty && node.type.name === 'paragraph') {
+ return 'Type \u2019/\u2019 to insert a block, or just start writing\u2026';
+ }
+ if (node.type.name === 'heading') {
+ return `Heading ${node.attrs['level']}`;
+ }
+ if (node.type.name === 'paragraph') {
+ return 'Type \u2019/\u2019 for commands';
+ }
+ if (node.type.name === 'blockquote') {
+ return 'Write a quote or callout\u2026';
+ }
+ return '';
+ }
+ })
+ ];
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/grid-resize.plugin.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/grid-resize.plugin.ts
new file mode 100644
index 000000000000..a28fa995b79d
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/grid-resize.plugin.ts
@@ -0,0 +1,277 @@
+import { Editor } from '@tiptap/core';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+// EditorView is intentionally not imported: TipTap 3.x nests its own prosemirror-view under
+// @tiptap/pm/node_modules, which causes TS2322 "separate declarations of a private property"
+// when the top-level prosemirror-view type is used in plugin PluginSpec callbacks.
+// Removing explicit annotations lets TypeScript infer the correct type from PluginSpec context.
+
+export const GRID_RESIZE_PLUGIN_KEY = new PluginKey('gridResize');
+
+/** Snap a drag ratio to the nearest 12-column grid split. */
+function snapToGrid(ratio: number): number[] {
+ const col1 = Math.min(11, Math.max(1, Math.round(ratio * 12)));
+
+ return [col1, 12 - col1];
+}
+
+/**
+ * Creates a preview overlay that sits on top of the grid block during drag.
+ * Two colored regions show the proposed column split, bypassing ProseMirror's DOM.
+ */
+function createPreviewOverlay(): HTMLDivElement {
+ const overlay = document.createElement('div');
+ overlay.className = 'grid-block__drag-preview';
+
+ const left = document.createElement('div');
+ left.className = 'grid-block__drag-preview-col';
+
+ const right = document.createElement('div');
+ right.className = 'grid-block__drag-preview-col';
+
+ overlay.appendChild(left);
+ overlay.appendChild(right);
+
+ return overlay;
+}
+
+/** Finds the column elements inside a gridBlock DOM element. */
+function getColumnElements(gridEl: HTMLElement): NodeListOf {
+ const gridInner = gridEl.querySelector('.grid-block__grid');
+
+ return gridInner
+ ? gridInner.querySelectorAll(':scope > [data-type="gridColumn"]')
+ : gridEl.querySelectorAll(':scope > [data-type="gridColumn"]');
+}
+
+export function GridResizePlugin(editor: Editor): Plugin {
+ let overlayContainer: HTMLDivElement | null = null;
+ let currentHandles: { handle: HTMLDivElement; gridEl: HTMLElement }[] = [];
+ let dragging = false;
+
+ function getOrCreateOverlay(view): HTMLDivElement {
+ if (!overlayContainer) {
+ overlayContainer = document.createElement('div');
+ overlayContainer.className = 'grid-block__resize-overlay';
+ overlayContainer.style.position = 'absolute';
+ overlayContainer.style.top = '0';
+ overlayContainer.style.left = '0';
+ overlayContainer.style.width = '0';
+ overlayContainer.style.height = '0';
+ overlayContainer.style.overflow = 'visible';
+ overlayContainer.style.pointerEvents = 'none';
+
+ const editorParent = view.dom.parentElement;
+
+ if (editorParent) {
+ editorParent.style.position = 'relative';
+ editorParent.appendChild(overlayContainer);
+ }
+ }
+
+ return overlayContainer;
+ }
+
+ function removeAllHandles() {
+ for (const { handle } of currentHandles) {
+ handle.remove();
+ }
+
+ currentHandles = [];
+ }
+
+ function positionHandle(
+ handle: HTMLDivElement,
+ gridEl: HTMLElement,
+ container: HTMLDivElement
+ ) {
+ const cols = getColumnElements(gridEl);
+ const firstCol = cols[0];
+ const secondCol = cols[1];
+
+ if (!firstCol) {
+ return;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const gridRect = gridEl.getBoundingClientRect();
+ const colRect = firstCol.getBoundingClientRect();
+
+ const centerX = secondCol
+ ? (colRect.right + secondCol.getBoundingClientRect().left) / 2
+ : colRect.right;
+
+ handle.style.left = `${centerX - containerRect.left - 6}px`;
+ handle.style.top = `${gridRect.top - containerRect.top}px`;
+ handle.style.height = `${gridRect.height}px`;
+ }
+
+ function findGridBlockPos(view, gridEl: HTMLElement): number | null {
+ const gridInner = gridEl.querySelector('.grid-block__grid');
+ const firstCol = gridInner
+ ? gridInner.querySelector('[data-type="gridColumn"]')
+ : gridEl.querySelector('[data-type="gridColumn"]');
+
+ if (!firstCol) {
+ return null;
+ }
+
+ try {
+ const pos = view.posAtDOM(firstCol, 0);
+ const $pos = view.state.doc.resolve(pos);
+
+ for (let d = $pos.depth; d >= 1; d--) {
+ if ($pos.node(d).type.name === 'gridColumn') {
+ return $pos.before(d - 1);
+ }
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+ }
+
+ function syncHandles(view) {
+ if (dragging) {
+ return;
+ }
+
+ removeAllHandles();
+
+ if (!editor.isEditable) {
+ return;
+ }
+
+ const container = getOrCreateOverlay(view);
+ const gridBlocks = (view.dom as HTMLElement).querySelectorAll(
+ '[data-type="gridBlock"]'
+ );
+
+ gridBlocks.forEach((gridEl) => {
+ const handle = document.createElement('div');
+ handle.className = 'grid-block__resize-handle';
+ handle.style.pointerEvents = 'auto';
+ container.appendChild(handle);
+ positionHandle(handle, gridEl, container);
+ currentHandles.push({ handle, gridEl });
+ attachDragListeners(handle, gridEl, view, container);
+ });
+ }
+
+ function attachDragListeners(
+ handle: HTMLDivElement,
+ gridEl: HTMLElement,
+ view,
+ container: HTMLDivElement
+ ) {
+ handle.addEventListener('pointerdown', (e: PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (dragging) {
+ return;
+ }
+
+ const gridBlockPos = findGridBlockPos(view, gridEl);
+
+ if (gridBlockPos == null) {
+ return;
+ }
+
+ const gridNode = view.state.doc.nodeAt(gridBlockPos);
+ const storedColumns: number[] = gridNode?.attrs?.['columns'] ?? [6, 6];
+ const startRatio = storedColumns[0] / 12;
+
+ const startX = e.clientX;
+ const gridRect = gridEl.getBoundingClientRect();
+ const gap = parseFloat(getComputedStyle(gridEl).gap) || 0;
+ const usableWidth = gridRect.width - gap;
+
+ dragging = true;
+ handle.classList.add('grid-block__resize-handle--active');
+
+ const preview = createPreviewOverlay();
+ const containerRect = container.getBoundingClientRect();
+ preview.style.left = `${gridRect.left - containerRect.left}px`;
+ preview.style.top = `${gridRect.top - containerRect.top}px`;
+ preview.style.width = `${gridRect.width}px`;
+ preview.style.height = `${gridRect.height}px`;
+ preview.style.gap = `${gap}px`;
+ container.appendChild(preview);
+
+ const previewCols = preview.querySelectorAll(
+ '.grid-block__drag-preview-col'
+ );
+ const previewLeft = previewCols[0];
+ const previewRight = previewCols[1];
+
+ const setPreviewRatio = (ratio: number) => {
+ const pct1 = ratio * 100;
+ const pct2 = (1 - ratio) * 100;
+ previewLeft.style.flex = `0 0 calc(${pct1}% - ${gap / 2}px)`;
+ previewRight.style.flex = `0 0 calc(${pct2}% - ${gap / 2}px)`;
+ };
+
+ setPreviewRatio(startRatio);
+
+ let currentRatio = startRatio;
+
+ const onPointerMove = (moveEvent: PointerEvent) => {
+ const deltaX = moveEvent.clientX - startX;
+ currentRatio = Math.min(
+ 11 / 12,
+ Math.max(1 / 12, startRatio + deltaX / usableWidth)
+ );
+
+ setPreviewRatio(currentRatio);
+ positionHandle(handle, gridEl, container);
+ };
+
+ const onPointerUp = () => {
+ document.removeEventListener('pointermove', onPointerMove);
+ document.removeEventListener('pointerup', onPointerUp);
+
+ handle.classList.remove('grid-block__resize-handle--active');
+ preview.remove();
+
+ const snappedColumns = snapToGrid(currentRatio);
+
+ dragging = false;
+
+ const node = view.state.doc.nodeAt(gridBlockPos);
+
+ if (node && node.type.name === 'gridBlock') {
+ const tr = view.state.tr.setNodeMarkup(gridBlockPos, undefined, {
+ ...node.attrs,
+ columns: snappedColumns
+ });
+ view.dispatch(tr);
+ }
+ };
+
+ document.addEventListener('pointermove', onPointerMove);
+ document.addEventListener('pointerup', onPointerUp);
+ });
+ }
+
+ return new Plugin({
+ key: GRID_RESIZE_PLUGIN_KEY,
+ view(view) {
+ requestAnimationFrame(() => syncHandles(view));
+
+ return {
+ update(updatedView) {
+ requestAnimationFrame(() => syncHandles(updatedView));
+ },
+ destroy() {
+ removeAllHandles();
+
+ if (overlayContainer) {
+ overlayContainer.remove();
+ overlayContainer = null;
+ }
+ }
+ };
+ }
+ });
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/grid.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/grid.extension.ts
new file mode 100644
index 000000000000..7fae565f70ea
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/grid.extension.ts
@@ -0,0 +1,324 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import { TextSelection } from '@tiptap/pm/state';
+
+import { GridResizePlugin } from './grid-resize.plugin';
+
+declare module '@tiptap/core' {
+ interface Commands {
+ gridBlock: {
+ insertGrid: () => ReturnType;
+ setGridColumns: (columns: number[]) => ReturnType;
+ };
+ }
+}
+
+// ── GridColumn ────────────────────────────────────────────────────────────────
+// Width is controlled exclusively by the parent gridBlock's grid-template-columns.
+// No span/width attribute lives here — gridBlock.columns is the single source of truth.
+
+export const GridColumn = Node.create({
+ name: 'gridColumn',
+ group: 'gridColumnGroup',
+ content: 'block+',
+ isolating: true,
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="gridColumn"]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes({ 'data-type': 'gridColumn' }, HTMLAttributes), 0];
+ },
+
+ addNodeView() {
+ return () => {
+ const dom = document.createElement('div');
+ dom.setAttribute('data-type', 'gridColumn');
+ dom.classList.add('grid-block__column');
+
+ const contentDOM = document.createElement('div');
+ contentDOM.classList.add('grid-block__column-content');
+ dom.appendChild(contentDOM);
+
+ return { dom, contentDOM };
+ };
+ }
+});
+
+// ── GridBlock ─────────────────────────────────────────────────────────────────
+
+export const GridBlock = Node.create({
+ name: 'gridBlock',
+ group: 'block',
+ content: 'gridColumn{2}',
+ defining: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ columns: {
+ default: [6, 6],
+ parseHTML: (element) => {
+ try {
+ const raw = element.getAttribute('data-columns');
+ const parsed = raw ? JSON.parse(raw) : null;
+
+ if (Array.isArray(parsed) && parsed.length === 2) return parsed;
+ } catch {
+ // ignore malformed JSON
+ }
+
+ return [6, 6];
+ },
+ renderHTML: (attributes) => {
+ const cols: number[] = Array.isArray(attributes['columns'])
+ ? (attributes['columns'] as number[])
+ : [6, 6];
+
+ return {
+ 'data-columns': JSON.stringify(cols),
+ style: `grid-template-columns: ${cols[0]}fr ${cols[1]}fr`
+ };
+ }
+ }
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="gridBlock"]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', mergeAttributes({ 'data-type': 'gridBlock' }, HTMLAttributes), 0];
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const dom = document.createElement('div');
+ dom.setAttribute('data-type', 'gridBlock');
+ dom.classList.add('grid-block');
+
+ const cols: number[] = (node.attrs['columns'] as number[]) ?? [6, 6];
+ dom.style.gridTemplateColumns = `${cols[0]}fr ${cols[1]}fr`;
+
+ // contentDOM: display:contents so gridColumn cells participate directly
+ // in the parent CSS Grid defined on dom.
+ const contentDOM = document.createElement('div');
+ contentDOM.classList.add('grid-block__grid');
+ dom.appendChild(contentDOM);
+
+ return {
+ dom,
+ contentDOM,
+ update(updatedNode) {
+ if (updatedNode.type.name !== 'gridBlock') return false;
+
+ const c: number[] = (updatedNode.attrs['columns'] as number[]) ?? [6, 6];
+ dom.style.gridTemplateColumns = `${c[0]}fr ${c[1]}fr`;
+
+ return true;
+ }
+ };
+ };
+ },
+
+ addCommands() {
+ return {
+ insertGrid:
+ () =>
+ ({ commands, state }) => {
+ // Prevent inserting a grid inside a grid column.
+ const { $from } = state.selection;
+
+ for (let d = $from.depth; d > 0; d--) {
+ if ($from.node(d).type.name === 'gridColumn') return false;
+ }
+
+ return commands.insertContent({
+ type: 'gridBlock',
+ attrs: { columns: [6, 6] },
+ content: [
+ { type: 'gridColumn', content: [{ type: 'paragraph' }] },
+ { type: 'gridColumn', content: [{ type: 'paragraph' }] }
+ ]
+ });
+ },
+
+ setGridColumns:
+ (columns: number[]) =>
+ ({ tr, state, dispatch }) => {
+ const { $from } = state.selection;
+
+ for (let depth = $from.depth; depth > 0; depth--) {
+ if ($from.node(depth).type.name === 'gridBlock') {
+ const pos = $from.before(depth);
+
+ if (dispatch) {
+ tr.setNodeMarkup(pos, undefined, {
+ ...$from.node(depth).attrs,
+ columns
+ });
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Backspace: ({ editor }) => {
+ const { state } = editor;
+ const { $from } = state.selection;
+
+ for (let depth = $from.depth; depth > 0; depth--) {
+ if ($from.node(depth).type.name === 'gridBlock') {
+ const gridNode = $from.node(depth);
+ const gridPos = $from.before(depth);
+
+ const bothEmpty =
+ gridNode.childCount === 2 &&
+ gridNode.child(0).childCount === 1 &&
+ gridNode.child(0).child(0).type.name === 'paragraph' &&
+ gridNode.child(0).child(0).textContent === '' &&
+ gridNode.child(1).childCount === 1 &&
+ gridNode.child(1).child(0).type.name === 'paragraph' &&
+ gridNode.child(1).child(0).textContent === '';
+
+ if (bothEmpty) {
+ const { tr } = state;
+ const paragraph = state.schema.nodes['paragraph'].create();
+ tr.replaceWith(gridPos, gridPos + gridNode.nodeSize, paragraph);
+ tr.setSelection(TextSelection.near(tr.doc.resolve(gridPos + 1)));
+ editor.view.dispatch(tr);
+
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ return false;
+ },
+
+ Delete: ({ editor }) => {
+ const { state } = editor;
+ const { $from, empty } = state.selection;
+
+ if (!empty) return false;
+
+ for (let depth = $from.depth; depth > 0; depth--) {
+ if ($from.node(depth).type.name === 'gridColumn') {
+ const cursorAtEnd = $from.pos === $from.end($from.depth);
+ const lastChildInColumn =
+ $from.index(depth) === $from.node(depth).childCount - 1;
+
+ if (cursorAtEnd && lastChildInColumn) return true;
+
+ return false;
+ }
+ }
+
+ return false;
+ },
+
+ Tab: ({ editor }) => {
+ const { state } = editor;
+ const { $from } = state.selection;
+
+ for (let depth = $from.depth; depth > 0; depth--) {
+ if ($from.node(depth).type.name === 'gridBlock') {
+ const gridNode = $from.node(depth);
+ const gridPos = $from.before(depth);
+
+ let columnIndex = -1;
+
+ for (let colDepth = $from.depth; colDepth > depth; colDepth--) {
+ if ($from.node(colDepth).type.name === 'gridColumn') {
+ const colPos = $from.before(colDepth);
+ let offset = 0;
+
+ for (let i = 0; i < gridNode.childCount; i++) {
+ if (gridPos + 1 + offset === colPos) {
+ columnIndex = i;
+ break;
+ }
+
+ offset += gridNode.child(i).nodeSize;
+ }
+
+ break;
+ }
+ }
+
+ if (columnIndex === 0 && gridNode.childCount > 1) {
+ const firstColSize = gridNode.child(0).nodeSize;
+ const secondColStart = gridPos + 1 + firstColSize + 1;
+ const resolvedPos = state.doc.resolve(secondColStart);
+ editor.commands.setTextSelection(TextSelection.near(resolvedPos).from);
+
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ return false;
+ },
+
+ 'Shift-Tab': ({ editor }) => {
+ const { state } = editor;
+ const { $from } = state.selection;
+
+ for (let depth = $from.depth; depth > 0; depth--) {
+ if ($from.node(depth).type.name === 'gridBlock') {
+ const gridNode = $from.node(depth);
+ const gridPos = $from.before(depth);
+
+ let columnIndex = -1;
+
+ for (let colDepth = $from.depth; colDepth > depth; colDepth--) {
+ if ($from.node(colDepth).type.name === 'gridColumn') {
+ const colPos = $from.before(colDepth);
+ let offset = 0;
+
+ for (let i = 0; i < gridNode.childCount; i++) {
+ if (gridPos + 1 + offset === colPos) {
+ columnIndex = i;
+ break;
+ }
+
+ offset += gridNode.child(i).nodeSize;
+ }
+
+ break;
+ }
+ }
+
+ if (columnIndex === 1) {
+ const firstColStart = gridPos + 1 + 1;
+ const resolvedPos = state.doc.resolve(firstColStart);
+ editor.commands.setTextSelection(TextSelection.near(resolvedPos).from);
+
+ return true;
+ }
+
+ return false;
+ }
+ }
+
+ return false;
+ }
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [GridResizePlugin(this.editor)];
+ }
+});
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/image.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/image.extension.ts
new file mode 100644
index 000000000000..738926ab6678
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/image.extension.ts
@@ -0,0 +1,103 @@
+import { mergeAttributes } from '@tiptap/core';
+import Image from '@tiptap/extension-image';
+
+declare module '@tiptap/core' {
+ interface Commands {
+ dotImage: {
+ setImageTextWrap: (value: 'left' | 'right') => ReturnType;
+ };
+ }
+}
+
+export const DotImage = Image.extend({
+ // Keep name 'image' — preserves compatibility with setImage(), updateAttributes('image', …),
+ // editor.isActive('image'), and existing stored content.
+ name: 'image',
+
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ textWrap: {
+ default: null,
+ // Read from the parent 's class — set by renderHTML()
+ parseHTML: (element) => {
+ const figure = element.closest('figure');
+ if (!figure) return null;
+ if (figure.classList.contains('image-wrap-left')) return 'left';
+ if (figure.classList.contains('image-wrap-right')) return 'right';
+ return null;
+ },
+ // textWrap goes on , not on
— return empty object
+ renderHTML: () => ({})
+ }
+ };
+ },
+
+ parseHTML() {
+ return [
+ // Primary: our serialized format —
+ { tag: 'figure img[src]' },
+ // Fallback: bare
tags from other sources / old content
+ { tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])' }
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const { textWrap, ...imgAttrs } = HTMLAttributes;
+ const figAttrs: Record = {};
+ if (textWrap) figAttrs['class'] = `image-wrap-${textWrap}`;
+
+ return [
+ 'figure',
+ figAttrs,
+ ['img', mergeAttributes(this.options.HTMLAttributes, imgAttrs)]
+ ];
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const figure = document.createElement('figure');
+ const img = document.createElement('img');
+
+ // Apply all attrs except textWrap to the
+ const { textWrap, ...imgAttrs } = node.attrs as Record;
+ Object.entries(imgAttrs).forEach(([key, value]) => {
+ if (value == null) return;
+ img.setAttribute(
+ key,
+ typeof value === 'object' ? JSON.stringify(value) : String(value)
+ );
+ });
+
+ // Apply wrap class to — CSS drives the float, not inline styles
+ figure.className = textWrap ? `image-wrap-${textWrap}` : '';
+
+ figure.appendChild(img);
+
+ return {
+ dom: figure,
+ selectNode() {
+ figure.classList.add('is-selected');
+ },
+ deselectNode() {
+ figure.classList.remove('is-selected');
+ }
+ };
+ };
+ },
+
+ addCommands() {
+ return {
+ ...this.parent?.(),
+ setImageTextWrap:
+ (value) =>
+ ({ commands, editor }) => {
+ const current = editor.getAttributes('image').textWrap;
+ // Toggle: clicking the same direction again clears it
+ return commands.updateAttributes('image', {
+ textWrap: current === value ? null : value
+ });
+ }
+ };
+ }
+});
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/slash-command.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/slash-command.extension.ts
new file mode 100644
index 000000000000..6e4aa25d94e2
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/slash-command.extension.ts
@@ -0,0 +1,86 @@
+import { Editor, Extension } from '@tiptap/core';
+import Suggestion, {
+ SuggestionKeyDownProps,
+ SuggestionPluginKey,
+ SuggestionProps
+} from '@tiptap/suggestion';
+
+import { BlockItem, SlashMenuService } from '../slash-menu/slash-menu.service';
+
+function hideDragGutterForSlashMenu(editor: Editor): void {
+ // TipTap's drag-handle plugin apply() runs both metas in one transaction but the
+ // hideDragHandle branch always sets locked = false after lockDragHandle ran, so the
+ // lock never sticks. Two transactions: hide first, then lock (mousemove stays no-op).
+ editor.chain().setMeta('hideDragHandle', true).run();
+ editor.chain().lockDragHandle().run();
+}
+
+function showDragGutterAfterSlashMenu(editor: Editor): void {
+ editor.commands.unlockDragHandle();
+}
+
+export function createSlashCommandExtension(menuService: SlashMenuService) {
+ return Extension.create({
+ name: 'slashCommand',
+
+ onDestroy() {
+ menuService.detachEditor();
+ },
+
+ addProseMirrorPlugins() {
+ menuService.attachEditor(this.editor);
+ return [
+ Suggestion({
+ editor: this.editor,
+ char: '/',
+ startOfLine: true,
+
+ items: ({ query }) => menuService.filterItems(query),
+
+ command: ({ editor, range, props }) => {
+ if (props.onSelect) {
+ // Always pass range so keepRange items can clean it up themselves later.
+ props.onSelect(editor, range);
+ // keepRange items (e.g. sub-menus) skip deleteRange so the suggestion
+ // session stays alive and keyboard navigation keeps working.
+ if (!props.keepRange) {
+ editor.chain().focus().deleteRange(range).run();
+ }
+ } else if (props.apply) {
+ props.apply(editor.chain().focus().deleteRange(range)).run();
+ }
+ },
+
+ render: () => ({
+ onStart: (props: SuggestionProps) => {
+ menuService.open(props.items, props.clientRect ?? null, props.command);
+ hideDragGutterForSlashMenu(props.editor);
+ },
+ onUpdate: (props: SuggestionProps) => {
+ menuService.update(
+ props.items,
+ props.clientRect ?? null,
+ props.command
+ );
+ hideDragGutterForSlashMenu(props.editor);
+ },
+ onExit: (props: SuggestionProps) => {
+ // Suggestion calls onExit for both real exits and for (moved && changed)
+ // while the slash match is still active — e.g. after we delete the query
+ // text and the decoration range/query updates in one transaction. Only
+ // tear down the Angular menu when the plugin has actually deactivated.
+ const slashState = SuggestionPluginKey.getState(props.editor.state);
+ if (slashState?.active) {
+ return;
+ }
+ menuService.close();
+ showDragGutterAfterSlashMenu(props.editor);
+ },
+ onKeyDown: ({ event }: SuggestionKeyDownProps) =>
+ menuService.handleKeyDown(event)
+ })
+ })
+ ];
+ }
+ });
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/upload-placeholder.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/upload-placeholder.extension.ts
new file mode 100644
index 000000000000..0177cf389393
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/upload-placeholder.extension.ts
@@ -0,0 +1,175 @@
+import { Editor, Node } from '@tiptap/core';
+import type { Node as PMNode } from '@tiptap/pm/model';
+
+/** Media kind shown in the placeholder UI and stored on the node. */
+export type UploadPlaceholderMediaType = 'image' | 'video';
+
+/** Payload used when inserting one or more upload placeholders. */
+export type UploadPlaceholderItem = {
+ id: string;
+ mediaType: UploadPlaceholderMediaType;
+};
+
+const PLACEHOLDER_NODE_NAME = 'uploadPlaceholder' as const;
+
+/**
+ * Locates the document position of an `uploadPlaceholder` node by its `id` attribute.
+ *
+ * @param doc - ProseMirror document to search.
+ * @param placeholderId - `attrs.id` of the placeholder to find.
+ * @returns Start position of the node, or `null` if not found.
+ */
+function findUploadPlaceholderPosition(doc: PMNode, placeholderId: string): number | null {
+ let targetPos: number | null = null;
+
+ doc.descendants((node, pos) => {
+ if (node.type.name === PLACEHOLDER_NODE_NAME && node.attrs['id'] === placeholderId) {
+ targetPos = pos;
+ return false;
+ }
+ return true;
+ });
+
+ return targetPos;
+}
+
+/**
+ * Material Symbol name for the placeholder row (host app must load the font).
+ *
+ * @param mediaType - Whether we are uploading an image or video.
+ * @returns Ligature text for `material-symbols-outlined`.
+ */
+function createUploadPlaceholderIcon(mediaType: UploadPlaceholderMediaType): HTMLElement {
+ const icon = document.createElement('span');
+ icon.className = 'material-symbols-outlined upload-placeholder__icon';
+ icon.setAttribute('aria-hidden', 'true');
+ icon.textContent = mediaType === 'video' ? 'videocam' : 'image';
+ return icon;
+}
+
+/**
+ * Visible “Uploading …” label next to the icon.
+ *
+ * @param mediaType - Drives the copy (`image` / `video`).
+ */
+function createUploadPlaceholderLabel(mediaType: UploadPlaceholderMediaType): HTMLElement {
+ const label = document.createElement('span');
+ label.className = 'upload-placeholder__label';
+ label.textContent = `Uploading ${mediaType}…`;
+ return label;
+}
+
+/**
+ * Indeterminate progress bar track (animated via global `.upload-placeholder__bar` CSS).
+ */
+function createUploadPlaceholderProgressBar(): HTMLElement {
+ const barTrack = document.createElement('span');
+ barTrack.className = 'upload-placeholder__bar';
+ return barTrack;
+}
+
+/**
+ * Root DOM for the node view: non-editable status row with icon, label, and bar.
+ *
+ * @param mediaType - Image vs video (icon + copy).
+ * @returns The `.upload-placeholder` element.
+ */
+function createUploadPlaceholderDom(mediaType: UploadPlaceholderMediaType): HTMLElement {
+ const dom = document.createElement('div');
+ dom.className = 'upload-placeholder';
+ dom.setAttribute('contenteditable', 'false');
+ dom.setAttribute('aria-label', `Uploading ${mediaType}…`);
+ dom.setAttribute('role', 'status');
+
+ dom.append(
+ createUploadPlaceholderIcon(mediaType),
+ createUploadPlaceholderLabel(mediaType),
+ createUploadPlaceholderProgressBar()
+ );
+
+ return dom;
+}
+
+/**
+ * Atomic block node shown while a file uploads; replaced or removed when the upload finishes.
+ *
+ * - Not selectable/draggable; `contenteditable="false"` in the node view.
+ * - Serializes as a `div` with `data-upload-id` and `data-media-type` for HTML export.
+ */
+export const UploadPlaceholderExtension = Node.create({
+ name: PLACEHOLDER_NODE_NAME,
+ group: 'block',
+ atom: true,
+ selectable: false,
+ draggable: false,
+
+ addAttributes() {
+ return {
+ id: { default: null },
+ mediaType: { default: 'image' }
+ };
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'div',
+ {
+ 'data-upload-id': HTMLAttributes['id'],
+ 'data-media-type': HTMLAttributes['mediaType']
+ }
+ ];
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const mediaType = node.attrs['mediaType'] as UploadPlaceholderMediaType;
+ return { dom: createUploadPlaceholderDom(mediaType) };
+ };
+ }
+});
+
+/**
+ * Inserts one or more upload placeholder nodes at `pos` (e.g. where a drop occurred).
+ *
+ * @param editor - Active TipTap editor.
+ * @param pos - Document position to insert at.
+ * @param placeholders - Temp ids and media types for each row.
+ */
+export function insertUploadPlaceholders(
+ editor: Editor,
+ pos: number,
+ placeholders: UploadPlaceholderItem[]
+): void {
+ const content = placeholders.map(({ id, mediaType }) => ({
+ type: PLACEHOLDER_NODE_NAME,
+ attrs: { id, mediaType }
+ }));
+ editor.chain().focus().insertContentAt(pos, content).run();
+}
+
+/**
+ * Replaces the placeholder node matching `placeholderId` with final TipTap content (e.g. image/video node).
+ *
+ * @param editor - Active TipTap editor.
+ * @param placeholderId - `attrs.id` of the placeholder to replace.
+ * @param content - JSON content or node spec passed to `insertContent`.
+ */
+export function replacePlaceholder(editor: Editor, placeholderId: string, content: object): void {
+ const targetPos = findUploadPlaceholderPosition(editor.state.doc, placeholderId);
+ if (targetPos !== null) {
+ editor.chain().setNodeSelection(targetPos).insertContent(content).run();
+ }
+}
+
+/**
+ * Deletes the placeholder node matching `placeholderId` (e.g. on upload error).
+ *
+ * @param editor - Active TipTap editor.
+ * @param placeholderId - `attrs.id` of the placeholder to remove.
+ */
+export function removePlaceholder(editor: Editor, placeholderId: string): void {
+ const targetPos = findUploadPlaceholderPosition(editor.state.doc, placeholderId);
+ if (targetPos !== null) {
+ editor.chain().setNodeSelection(targetPos).deleteSelection().run();
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/video.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/video.extension.ts
new file mode 100644
index 000000000000..c1ca097551ac
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/video.extension.ts
@@ -0,0 +1,51 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+
+export const Video = Node.create({
+ name: 'video',
+ group: 'block',
+ atom: true,
+
+ addAttributes() {
+ return {
+ src: { default: null },
+ title: { default: null }
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'video[src]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'video',
+ mergeAttributes({ controls: true, class: 'w-full rounded' }, HTMLAttributes)
+ ];
+ },
+
+ addNodeView() {
+ return ({ node }) => {
+ const dom = document.createElement('video');
+ dom.setAttribute('controls', '');
+ dom.classList.add('w-full', 'rounded');
+
+ if (node.attrs.src) {
+ dom.setAttribute('src', String(node.attrs.src));
+ }
+
+ if (node.attrs.title) {
+ dom.setAttribute('title', String(node.attrs.title));
+ }
+
+ return {
+ dom,
+ selectNode() {
+ dom.classList.add('is-selected');
+ },
+ deselectNode() {
+ dom.classList.remove('is-selected');
+ }
+ };
+ };
+ }
+});
diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-content-type.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-content-type.service.ts
new file mode 100644
index 000000000000..f2fd697c95c4
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-content-type.service.ts
@@ -0,0 +1,41 @@
+import { Observable } from 'rxjs';
+
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+
+import { map } from 'rxjs/operators';
+
+import { DOT_CMS_AUTH_TOKEN, DOT_CMS_BASE_URL } from './dot-cms.config';
+
+export interface DotCmsContentType {
+ id: string;
+ name: string;
+ variable: string;
+ description: string;
+ baseType: string;
+ icon: string;
+}
+
+interface ContentTypeFilterResponse {
+ entity: DotCmsContentType[];
+}
+
+@Injectable({ providedIn: 'root' })
+export class DotCmsContentTypeService {
+ private readonly http = inject(HttpClient);
+
+ fetchAll(): Observable {
+ const headers = new HttpHeaders({
+ Authorization: `Bearer ${DOT_CMS_AUTH_TOKEN}`,
+ 'Content-Type': 'application/json'
+ });
+
+ return this.http
+ .post(
+ `${DOT_CMS_BASE_URL}/api/v1/contenttype/_filter`,
+ { filter: { query: '' }, orderBy: 'name', direction: 'ASC', perPage: 40 },
+ { headers }
+ )
+ .pipe(map((res) => res.entity));
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-contentlet.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-contentlet.service.ts
new file mode 100644
index 000000000000..8504dfe8f42b
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-contentlet.service.ts
@@ -0,0 +1,172 @@
+import { Observable } from 'rxjs';
+
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+
+import { map } from 'rxjs/operators';
+
+import { DOT_CMS_AUTH_TOKEN, DOT_CMS_BASE_URL } from './dot-cms.config';
+
+export interface DotCmsContentlet {
+ inode: string;
+ identifier: string;
+ title: string;
+ contentType: string;
+ modDate: string;
+ [key: string]: unknown;
+}
+
+/** One page from POST /api/content/_search (for paginated UI). */
+export interface DotCmsContentSearchPage {
+ contentlets: DotCmsContentlet[];
+ totalRecords: number;
+}
+
+/** POST /api/content/_search wraps results in ResponseEntityView → SearchView. */
+interface ContentSearchResponse {
+ entity?: {
+ contentTook?: number;
+ jsonObjectView?: { contentlets?: DotCmsContentlet[] };
+ queryTook?: number;
+ resultsSize?: number;
+ };
+}
+
+/** Default Lucene query for image dotAssets / file assets (matches dotCMS image picker search). */
+const DEFAULT_DOTCMS_IMAGE_SEARCH_QUERY =
+ "+catchall:* title:''^15 +languageId:1 +baseType:(4 OR 9) +metadata.contenttype:image/* +deleted:false +working:true";
+
+/** Default Lucene query for video dotAssets / file assets. */
+const DEFAULT_DOTCMS_VIDEO_SEARCH_QUERY =
+ "+catchall:* title:''^15 +languageId:1 +baseType:(4 OR 9) +metadata.contenttype:video/* +deleted:false +working:true";
+
+@Injectable({ providedIn: 'root' })
+export class DotCmsContentletService {
+ private readonly http = inject(HttpClient);
+
+ /**
+ * Search published image assets via POST /api/content/_search.
+ * @param text Optional filter; when empty, uses the default broad image query.
+ */
+ searchImages(
+ params: { text?: string; offset?: number; limit?: number } = {}
+ ): Observable {
+ const limit = params.limit ?? 20;
+ const offset = params.offset ?? 0;
+ const raw = params.text?.trim() ?? '';
+ const query = raw
+ ? DotCmsContentletService.buildFilteredImageQuery(raw)
+ : DEFAULT_DOTCMS_IMAGE_SEARCH_QUERY;
+
+ return this.postContentSearch(query, limit, offset);
+ }
+
+ /**
+ * Search published video assets via POST /api/content/_search.
+ * @param text Optional filter; when empty, uses the default broad video query.
+ */
+ searchVideos(
+ params: { text?: string; offset?: number; limit?: number } = {}
+ ): Observable {
+ const limit = params.limit ?? 20;
+ const offset = params.offset ?? 0;
+ const raw = params.text?.trim() ?? '';
+ const query = raw
+ ? DotCmsContentletService.buildFilteredVideoQuery(raw)
+ : DEFAULT_DOTCMS_VIDEO_SEARCH_QUERY;
+
+ return this.postContentSearch(query, limit, offset);
+ }
+
+ private postContentSearch(
+ query: string,
+ limit: number,
+ offset: number
+ ): Observable {
+ const headers = new HttpHeaders({
+ Authorization: `Bearer ${DOT_CMS_AUTH_TOKEN}`,
+ 'Content-Type': 'application/json'
+ });
+
+ return this.http
+ .post(
+ `${DOT_CMS_BASE_URL}/api/content/_search`,
+ {
+ query,
+ sort: 'score,modDate desc',
+ limit,
+ offset
+ },
+ { headers }
+ )
+ .pipe(
+ map((res) => {
+ const contentlets = res.entity?.jsonObjectView?.contentlets ?? [];
+ const reported = res.entity?.resultsSize;
+ let totalRecords: number;
+ if (typeof reported === 'number' && !Number.isNaN(reported)) {
+ totalRecords = reported;
+ } else if (contentlets.length < limit) {
+ // Last (or only) page — exact count when API omits resultsSize
+ totalRecords = offset + contentlets.length;
+ } else {
+ // Full page but no total from API — assume at least one more row so paginator appears
+ totalRecords = offset + contentlets.length + 1;
+ }
+ return { contentlets, totalRecords };
+ })
+ );
+ }
+
+ private static escapeLuceneToken(term: string): string {
+ const specials = '+-&|!(){}[]^"~*?:\\';
+ let out = '';
+ for (const ch of term) {
+ out += specials.includes(ch) ? `\\${ch}` : ch;
+ }
+ return out;
+ }
+
+ /** Narrow results with one or more whitespace-separated tokens (each as +catchall:token*). */
+ private static buildFilteredImageQuery(text: string): string {
+ return DotCmsContentletService.buildFilteredAssetQuery(
+ text,
+ '+languageId:1 +baseType:(4 OR 9) +metadata.contenttype:image/* +deleted:false +working:true'
+ );
+ }
+
+ private static buildFilteredVideoQuery(text: string): string {
+ return DotCmsContentletService.buildFilteredAssetQuery(
+ text,
+ '+languageId:1 +baseType:(4 OR 9) +metadata.contenttype:video/* +deleted:false +working:true'
+ );
+ }
+
+ private static buildFilteredAssetQuery(text: string, base: string): string {
+ const tokens = text.trim().split(/\s+/).filter(Boolean);
+ const catchalls = tokens
+ .map((t) => `+catchall:${DotCmsContentletService.escapeLuceneToken(t)}*`)
+ .join(' ');
+ return `${catchalls} ${base}`;
+ }
+
+ fetchByType(variable: string): Observable {
+ const headers = new HttpHeaders({
+ Authorization: `Bearer ${DOT_CMS_AUTH_TOKEN}`,
+ 'Content-Type': 'application/json'
+ });
+
+ return this.http
+ .post(
+ `${DOT_CMS_BASE_URL}/api/content/_search`,
+ {
+ query: `+contentType:${variable} +languageId:1 +deleted:false +working:true +catchall:** title:''^15`,
+ sort: 'modDate desc',
+ offset: 0,
+ limit: 40
+ },
+ { headers }
+ )
+ .pipe(map((res) => res.entity?.jsonObjectView?.contentlets ?? []));
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-upload.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-upload.service.ts
new file mode 100644
index 000000000000..c75e06c06bf6
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-upload.service.ts
@@ -0,0 +1,93 @@
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+
+import { map, take } from 'rxjs/operators';
+
+import { DOT_CMS_AUTH_TOKEN, DOT_CMS_BASE_URL } from './dot-cms.config';
+
+const BASE_URL = DOT_CMS_BASE_URL;
+const AUTH_TOKEN = DOT_CMS_AUTH_TOKEN;
+
+@Injectable({ providedIn: 'root' })
+export class DotCmsUploadService {
+ private readonly http = inject(HttpClient);
+
+ private authHeaders(): HttpHeaders {
+ return new HttpHeaders({
+ Authorization: `Bearer ${AUTH_TOKEN}`
+ });
+ }
+
+ async uploadImage(file: File): Promise {
+ return this.uploadAsset(file);
+ }
+
+ async uploadVideo(file: File): Promise {
+ return this.uploadAsset(file);
+ }
+
+ private async uploadAsset(file: File): Promise {
+ const tempId = await this.uploadToTemp(file).pipe(take(1)).toPromise();
+ if (tempId === undefined) {
+ throw new Error('Temp upload: no value emitted');
+ }
+ const url = await this.publishAsset(tempId).pipe(take(1)).toPromise();
+ if (url === undefined) {
+ throw new Error('Publish: no value emitted');
+ }
+ return url;
+ }
+
+ private uploadToTemp(file: File) {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ return this.http
+ .post<{ tempFiles: { id: string }[] }>(`${BASE_URL}/api/v1/temp`, formData, {
+ headers: this.authHeaders()
+ })
+ .pipe(
+ map((body) => {
+ const id = body.tempFiles?.[0]?.id;
+ if (!id) throw new Error('Temp upload: missing temp file id');
+ return id;
+ })
+ );
+ }
+
+ private publishAsset(tempId: string) {
+ interface PublishBody {
+ entity: { results: Array> };
+ }
+
+ return this.http
+ .post(
+ `${BASE_URL}/api/v1/workflow/actions/default/fire/PUBLISH`,
+ {
+ contentlets: [
+ {
+ baseType: 'dotAsset',
+ asset: tempId,
+ hostFolder: '',
+ indexPolicy: 'WAIT_FOR'
+ }
+ ]
+ },
+ {
+ headers: this.authHeaders().set(
+ 'Content-Type',
+ 'application/json;charset=UTF-8'
+ )
+ }
+ )
+ .pipe(
+ map((body) => {
+ const row = body.entity?.results?.[0];
+ if (!row) throw new Error('Publish: missing results');
+ const contentlet = Object.values(row)[0] as { asset: string } | undefined;
+ if (!contentlet?.asset) throw new Error('Publish: missing asset path');
+ return `${BASE_URL}${contentlet.asset}`;
+ })
+ );
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms.config.ts b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms.config.ts
new file mode 100644
index 000000000000..96d2c248cb50
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms.config.ts
@@ -0,0 +1,7 @@
+/**
+ * Local demo dotCMS instance only. Auth and base URL are hardcoded for this workspace;
+ * do not use this pattern for production or shared repos.
+ */
+export const DOT_CMS_BASE_URL = 'http://localhost:8080';
+export const DOT_CMS_AUTH_TOKEN =
+ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhcGljNjI1Yjg1NC0zYzc2LTRjMjItYTc0Yy00MWI1M2NkYmYwMzkiLCJ4bW9kIjoxNzc1NzY3MDM0MDAwLCJuYmYiOjE3NzU3NjcwMzQsImlzcyI6ImRvdGNtcy1wcm9kdWN0aW9uIiwibGFiZWwiOiJkZXYiLCJleHAiOjE4NzA0MDE2MDAsImlhdCI6MTc3NTc2NzAzNCwianRpIjoiOGI1M2VmNmYtNzA4OS00NThmLThjMjQtNDMzN2Y1MmNiMGRmIn0.4Y4SMqhMDG0vJ4xbMTZ2AtSAIeyB5NEgZ7yIUMWkASg';
diff --git a/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu-catalog.ts b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu-catalog.ts
new file mode 100644
index 000000000000..a09141952c00
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu-catalog.ts
@@ -0,0 +1,362 @@
+import { take } from 'rxjs/operators';
+
+import type { Editor } from '@tiptap/core';
+import { SuggestionPluginKey } from '@tiptap/suggestion';
+
+import { DOT_CONTENTLET_NODE_NAME } from '../extensions/contentlet.extension';
+
+import type { BlockItem } from './slash-menu.types';
+import type { ImageDialogService } from '../components/image/image-dialog.service';
+import type { TableDialogService } from '../components/table/table-dialog.service';
+import type { VideoDialogService } from '../components/video/video-dialog.service';
+import type {
+ DotCmsContentType,
+ DotCmsContentTypeService
+} from '../services/dot-cms-content-type.service';
+import type {
+ DotCmsContentlet,
+ DotCmsContentletService
+} from '../services/dot-cms-contentlet.service';
+
+// Narrow interface so the catalog doesn't import the full service class
+interface SlashMenuSubMenuHost {
+ openSubmenu(): void;
+ setItems(items: BlockItem[], commandFn: (item: BlockItem) => void): void;
+ close(): void;
+}
+
+function clearActiveSuggestionRange(editor: Editor): void {
+ const match = SuggestionPluginKey.getState(editor.state);
+ if (match?.active) {
+ editor.chain().focus().deleteRange(match.range).run();
+ }
+}
+
+export function createContentTypeItem(
+ menuService: SlashMenuSubMenuHost,
+ contentTypeService: DotCmsContentTypeService,
+ contentletService: DotCmsContentletService
+): BlockItem {
+ return {
+ label: 'Content type',
+ description: 'Insert a dotCMS content type',
+ icon: '⬡',
+ keywords: ['content', 'type', 'dotcms', 'contenttype', 'model'],
+ blockName: 'contentlet',
+ keepRange: true,
+ onSelect: (editor, range) => {
+ // keepRange=true: deleteRange was skipped, suggestion session stays alive.
+ menuService.openSubmenu();
+
+ // Delete the query text (e.g. "content") but keep the "/" so Tiptap's
+ // suggestion resets to an empty query. The user can then type to filter
+ // content types. range.from is the position of "/", range.from+1 onwards
+ // is the query text.
+ if (range && range.from + 1 < range.to) {
+ editor
+ .chain()
+ .deleteRange({ from: range.from + 1, to: range.to })
+ .run();
+ }
+
+ contentTypeService
+ .fetchAll()
+ .pipe(take(1))
+ .toPromise()
+ .then((types: DotCmsContentType[] | undefined) => {
+ const resolvedTypes = types ?? [];
+ // Content type items are plain display items — drill-down logic lives in the
+ // commandFn below (closure over editor and services).
+ const typeItems: BlockItem[] =
+ resolvedTypes.length > 0
+ ? resolvedTypes.map((ct) => ({
+ label: ct.name,
+ description: ct.description || ct.variable,
+ icon: ct.icon || '⬡',
+ keywords: [ct.variable, ct.baseType.toLowerCase()]
+ }))
+ : [
+ {
+ label: 'No content types found',
+ description:
+ 'No types returned from the API. Check permissions or configuration.',
+ keywords: ['no', 'empty', 'content', 'types'],
+ isEmptyState: true
+ }
+ ];
+
+ menuService.setItems(typeItems, (selectedItem) => {
+ if (selectedItem.isEmptyState) {
+ clearActiveSuggestionRange(editor);
+ menuService.close();
+ return;
+ }
+
+ menuService.openSubmenu();
+
+ const slashMatch = SuggestionPluginKey.getState(editor.state);
+ if (slashMatch?.active && slashMatch.range.from + 1 < slashMatch.range.to) {
+ editor
+ .chain()
+ .deleteRange({
+ from: slashMatch.range.from + 1,
+ to: slashMatch.range.to
+ })
+ .run();
+ }
+
+ // keywords[0] is ct.variable (stored above)
+ const ctVariable = selectedItem.keywords[0];
+
+ contentletService
+ .fetchByType(ctVariable)
+ .pipe(take(1))
+ .toPromise()
+ .then((contentlets: DotCmsContentlet[] | undefined) => {
+ const resolvedContentlets = contentlets ?? [];
+ const contentletItems: BlockItem[] = resolvedContentlets.map(
+ (cl) => ({
+ label: cl.title || cl.identifier,
+ description: cl.contentType,
+ icon: '◈',
+ keywords: [cl.contentType, cl.identifier],
+ onSelect: (ed) => {
+ const match = SuggestionPluginKey.getState(ed.state);
+ const chain = ed.chain().focus();
+ if (match?.active) {
+ chain.deleteRange(match.range);
+ }
+ chain
+ .insertContent({
+ type: DOT_CONTENTLET_NODE_NAME,
+ attrs: {
+ inode: cl.inode,
+ identifier: cl.identifier,
+ title: cl.title ?? '',
+ contentType: cl.contentType ?? '',
+ modDate: cl.modDate ?? null
+ }
+ })
+ .run();
+ }
+ })
+ );
+
+ const finalItems: BlockItem[] =
+ contentletItems.length === 0
+ ? [
+ {
+ label: 'No contentlets found',
+ description: `No ${selectedItem.label} contentlets available`,
+ icon: '',
+ keywords: ['no', 'empty', 'contentlets'],
+ isEmptyState: true
+ }
+ ]
+ : contentletItems;
+
+ menuService.setItems(finalItems, (contentletItem) => {
+ if (contentletItem.onSelect) {
+ contentletItem.onSelect(editor);
+ } else {
+ clearActiveSuggestionRange(editor);
+ }
+ menuService.close();
+ });
+ })
+ .catch(() => {
+ menuService.setItems(
+ [
+ {
+ label: 'Could not load contentlets',
+ description:
+ 'The request failed. Check your connection and try again.',
+ icon: '',
+ keywords: ['error', 'contentlets'],
+ isEmptyState: true
+ }
+ ],
+ (contentletItem) => {
+ if (!contentletItem.onSelect) {
+ clearActiveSuggestionRange(editor);
+ }
+ menuService.close();
+ }
+ );
+ });
+ });
+ })
+ .catch(() => {
+ menuService.setItems(
+ [
+ {
+ label: 'Could not load content types',
+ description:
+ 'The request failed. Check your connection and API token.',
+ icon: '',
+ keywords: ['error', 'content', 'types'],
+ isEmptyState: true
+ }
+ ],
+ (item) => {
+ if (item.isEmptyState) {
+ clearActiveSuggestionRange(editor);
+ }
+ menuService.close();
+ }
+ );
+ });
+ }
+ };
+}
+
+export const ALL_ITEMS: BlockItem[] = [
+ {
+ label: 'Text',
+ description: 'Plain text paragraph',
+ icon: 'P',
+ keywords: ['paragraph', 'text'],
+ blockName: 'paragraph',
+ apply: (c) => c.setParagraph()
+ },
+ {
+ label: 'Heading 1',
+ description: 'Top-level title or page heading',
+ icon: 'H1',
+ keywords: ['h1', 'heading', 'title'],
+ blockName: 'heading',
+ apply: (c) => c.setHeading({ level: 1 })
+ },
+ {
+ label: 'Heading 2',
+ description: 'Section heading',
+ icon: 'H2',
+ keywords: ['h2', 'heading', 'subtitle'],
+ blockName: 'heading',
+ apply: (c) => c.setHeading({ level: 2 })
+ },
+ {
+ label: 'Heading 3',
+ description: 'Subsection heading',
+ icon: 'H3',
+ keywords: ['h3', 'heading'],
+ blockName: 'heading',
+ apply: (c) => c.setHeading({ level: 3 })
+ },
+ {
+ label: 'Bullet List',
+ description: 'Unordered list of items',
+ icon: '•',
+ keywords: ['ul', 'list', 'bullets'],
+ blockName: 'bulletList',
+ apply: (c) => c.toggleBulletList()
+ },
+ {
+ label: 'Ordered List',
+ description: 'Numbered list of steps or items',
+ icon: '1.',
+ keywords: ['ol', 'numbered', 'list'],
+ blockName: 'orderedList',
+ apply: (c) => c.toggleOrderedList()
+ },
+ {
+ label: 'Blockquote',
+ description: 'Highlighted quote or callout',
+ icon: '"',
+ keywords: ['quote', 'callout', 'cite'],
+ blockName: 'blockquote',
+ apply: (c) => c.toggleBlockquote()
+ },
+ {
+ label: 'Code Block',
+ description: 'Code snippet with syntax highlighting',
+ icon: '>',
+ keywords: ['code', 'pre', 'snippet'],
+ blockName: 'codeBlock',
+ apply: (c) => c.setCodeBlock()
+ },
+ {
+ label: 'Grid (2 columns)',
+ description: 'Two-column layout',
+ icon: 'view_column',
+ keywords: ['grid', 'columns', 'layout', 'two-column'],
+ blockName: 'gridBlock',
+ apply: (c) => c.insertGrid()
+ }
+];
+
+export interface SlashDialogServices {
+ table: TableDialogService;
+ image: ImageDialogService;
+ video: VideoDialogService;
+}
+
+/** Slash entries that open a floating dialog before mutating the document. */
+export function createSlashDialogBlockItems(services: SlashDialogServices): BlockItem[] {
+ const { table, image, video } = services;
+
+ return [
+ {
+ label: 'Table',
+ description: 'Organize data in rows and columns',
+ icon: '⊞',
+ keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
+ blockName: 'table',
+ onSelect: (editor) => {
+ const { from } = editor.state.selection;
+ const coords = editor.view.coordsAtPos(from);
+ const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
+ table.open(
+ (config) => {
+ editor.chain().focus().insertTable(config).run();
+ },
+ () => rect
+ );
+ }
+ },
+ {
+ label: 'Image',
+ description: 'Add a photo or graphic',
+ icon: '🖼',
+ keywords: ['image', 'photo', 'picture', 'upload', 'url'],
+ blockName: 'image',
+ onSelect: (editor) => {
+ const { from } = editor.state.selection;
+ const coords = editor.view.coordsAtPos(from);
+ const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
+ image.open(
+ (src, title, alt) => {
+ editor
+ .chain()
+ .focus()
+ .setImage({ src, title: title || undefined, alt: alt || undefined })
+ .run();
+ },
+ () => rect
+ );
+ }
+ },
+ {
+ label: 'Video',
+ description: 'Embed a video from a link or file',
+ icon: '▶',
+ keywords: ['video', 'mp4', 'upload', 'url', 'media'],
+ blockName: 'video',
+ onSelect: (editor) => {
+ const { from } = editor.state.selection;
+ const coords = editor.view.coordsAtPos(from);
+ const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
+ video.open(
+ (src, title) => {
+ editor
+ .chain()
+ .focus()
+ .insertContent({ type: 'video', attrs: { src, title: title ?? null } })
+ .run();
+ },
+ () => rect
+ );
+ }
+ }
+ ];
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.component.ts b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.component.ts
new file mode 100644
index 000000000000..7e99e300ce00
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.component.ts
@@ -0,0 +1,151 @@
+import { computePosition, flip, offset, shift } from '@floating-ui/dom';
+
+import { DOCUMENT } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ afterRenderEffect,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
+
+import { SlashMenuService } from './slash-menu.service';
+
+@Component({
+ selector: 'dot-block-editor-slash-menu',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [],
+ host: {
+ role: 'listbox',
+ 'aria-label': 'Block type menu',
+ id: 'slash-command-menu',
+ 'aria-live': 'polite',
+ tabindex: '-1',
+ class: 'fixed z-50 w-72 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
+ '[style.display]': 'service.isOpen() ? null : "none"',
+ '[style.visibility]': 'positioned() ? "visible" : "hidden"',
+ '[style.left.px]': 'floatX()',
+ '[style.top.px]': 'floatY()',
+ '(pointerdown.capture)': 'onHostPointerDownCapture()'
+ },
+ template: `
+
+ @for (item of service.items(); track item.label; let i = $index) {
+ -
+ @if (item.icon) {
+
+ {{ item.icon }}
+
+ }
+
+
+ {{ item.label }}
+
+ {{ item.description }}
+
+
+ }
+ @if (service.isLoading()) {
+ -
+
+ progress_activity
+
+ Loading content types…
+
+ } @else if (service.items().length === 0) {
+
+ No matching blocks
+
+ }
+
+ `
+})
+export class SlashMenuComponent {
+ protected readonly service = inject(SlashMenuService);
+ private readonly el = inject(ElementRef);
+ private readonly zone = inject(NgZone);
+ private readonly document = inject(DOCUMENT);
+
+ protected onHostPointerDownCapture(): void {
+ this.service.prepareMenuPointerInteraction();
+ }
+
+ protected readonly floatX = signal(0);
+ protected readonly floatY = signal(0);
+ // Starts false on every open; prevents a 0,0 flash before computePosition resolves
+ protected readonly positioned = signal(false);
+ private readonly scrollTick = signal(0);
+
+ constructor() {
+ effect((onCleanup) => {
+ if (!this.service.isOpen()) return;
+
+ const onScroll = () => this.scrollTick.update((n) => n + 1);
+ this.document.addEventListener('scroll', onScroll, { passive: true, capture: true });
+ onCleanup(() => {
+ this.document.removeEventListener('scroll', onScroll, { capture: true });
+ });
+ });
+
+ afterRenderEffect(() => {
+ this.scrollTick();
+ const isOpen = this.service.isOpen();
+ const clientRectFn = this.service.clientRectFn();
+
+ if (!isOpen || !clientRectFn) {
+ untracked(() => this.positioned.set(false));
+ return;
+ }
+
+ const virtualRef = {
+ getBoundingClientRect: () => clientRectFn() ?? new DOMRect()
+ };
+
+ // Host uses `position: fixed` (Tailwind `fixed`). Floating UI must use the same
+ // strategy or `left`/`top` are interpreted in the wrong space (large offset vs `/`).
+ computePosition(virtualRef, this.el.nativeElement, {
+ placement: 'bottom-start',
+ strategy: 'fixed',
+ middleware: [offset(4), flip(), shift({ padding: 8 })]
+ }).then(({ x, y }) => {
+ this.zone.run(() => {
+ untracked(() => {
+ this.floatX.set(x);
+ this.floatY.set(y);
+ this.positioned.set(true);
+ });
+ });
+ });
+ });
+ }
+
+ itemClass(i: number): string {
+ const base =
+ 'flex w-full cursor-pointer items-center gap-3 rounded px-2 py-1.5 transition-colors';
+ return this.service.activeIndex() === i
+ ? `${base} bg-blue-50`
+ : `${base} hover:bg-gray-100`;
+ }
+
+ onMouseMove(i: number): void {
+ if (i !== this.service.activeIndex()) {
+ this.service.activeIndex.set(i);
+ }
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.service.ts b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.service.ts
new file mode 100644
index 000000000000..750eb760c423
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.service.ts
@@ -0,0 +1,215 @@
+import { Injectable, NgZone, computed, inject, signal } from '@angular/core';
+
+import type { Editor } from '@tiptap/core';
+
+import {
+ ALL_ITEMS,
+ createContentTypeItem,
+ createSlashDialogBlockItems
+} from './slash-menu-catalog';
+
+import { ImageDialogService } from '../components/image/image-dialog.service';
+import { TableDialogService } from '../components/table/table-dialog.service';
+import { VideoDialogService } from '../components/video/video-dialog.service';
+import { DotCmsContentTypeService } from '../services/dot-cms-content-type.service';
+import { DotCmsContentletService } from '../services/dot-cms-contentlet.service';
+
+import type { BlockItem } from './slash-menu.types';
+
+export type { BlockItem } from './slash-menu.types';
+export { ALL_ITEMS } from './slash-menu-catalog';
+
+@Injectable({ providedIn: 'root' })
+export class SlashMenuService {
+ private readonly zone = inject(NgZone);
+ private readonly tableDialogService = inject(TableDialogService);
+ private readonly imageDialogService = inject(ImageDialogService);
+ private readonly videoDialogService = inject(VideoDialogService);
+ private readonly contentTypeService = inject(DotCmsContentTypeService);
+ private readonly contentletService = inject(DotCmsContentletService);
+
+ private readonly dialogBlockItems = createSlashDialogBlockItems({
+ table: this.tableDialogService,
+ image: this.imageDialogService,
+ video: this.videoDialogService
+ });
+
+ private readonly contentTypeItem = createContentTypeItem(
+ this,
+ this.contentTypeService,
+ this.contentletService
+ );
+
+ readonly allowedBlocks = signal(null);
+
+ filterItems(query: string): BlockItem[] {
+ // While in a sub-menu, filter the content type list instead of the regular items.
+ if (this.isInSubmenu) {
+ const q = query.toLowerCase().trim();
+ if (!q) return this.subMenuAllItems;
+ return this.subMenuAllItems.filter(
+ (item) =>
+ item.label.toLowerCase().includes(q) || item.keywords.some((k) => k.includes(q))
+ );
+ }
+
+ const all = [this.contentTypeItem, ...ALL_ITEMS, ...this.dialogBlockItems];
+
+ const allowed = this.allowedBlocks();
+ const filtered = allowed
+ ? all.filter(
+ (item) =>
+ !item.blockName ||
+ item.blockName === 'paragraph' ||
+ allowed.includes(item.blockName)
+ )
+ : all;
+
+ const q = query.toLowerCase().trim();
+ if (!q) return filtered;
+ return filtered.filter(
+ (item) =>
+ item.label.toLowerCase().includes(q) || item.keywords.some((k) => k.includes(q))
+ );
+ }
+
+ readonly items = signal([]);
+ readonly isOpen = signal(false);
+ readonly isLoading = signal(false);
+ readonly activeIndex = signal(0);
+ readonly clientRectFn = signal<(() => DOMRect | null) | null>(null);
+ readonly activeOptionId = computed(() =>
+ this.isOpen() && this.items().length > 0 ? `slash-opt-${this.activeIndex()}` : null
+ );
+
+ private commandFn: ((item: BlockItem) => void) | null = null;
+ private editor: Editor | null = null;
+ private isInSubmenu = false;
+ // Full unfiltered sub-menu list — kept separate so filterItems() can re-filter it
+ // as the user types while the sub-menu is open.
+ private subMenuAllItems: BlockItem[] = [];
+
+ /** Set by slash-command extension so menu clicks can re-focus the editor before selection runs. */
+ attachEditor(editor: Editor): void {
+ this.editor = editor;
+ }
+
+ detachEditor(): void {
+ this.editor = null;
+ }
+
+ /** Call from pointerdown capture on the menu so focus returns before the target runs. */
+ prepareMenuPointerInteraction(): void {
+ this.editor?.view.focus();
+ }
+
+ open(
+ items: BlockItem[],
+ clientRectFn: (() => DOMRect | null) | null,
+ commandFn: (item: BlockItem) => void
+ ): void {
+ this.zone.run(() => {
+ this.items.set(items);
+ this.clientRectFn.set(clientRectFn);
+ this.commandFn = commandFn;
+ this.activeIndex.set(0);
+ this.isOpen.set(true);
+ });
+ }
+
+ update(
+ items: BlockItem[],
+ clientRectFn: (() => DOMRect | null) | null,
+ commandFn: (item: BlockItem) => void
+ ): void {
+ if (this.isInSubmenu) {
+ // items is already the result of filterItems() — apply it but keep our commandFn
+ // (Tiptap's commandFn would wrongly call deleteRange on the slash trigger).
+ this.zone.run(() => {
+ this.items.set(items);
+ this.activeIndex.set(0);
+ });
+ return;
+ }
+ this.zone.run(() => {
+ this.items.set(items);
+ this.clientRectFn.set(clientRectFn);
+ this.commandFn = commandFn;
+ this.activeIndex.set(0);
+ });
+ }
+
+ close(): void {
+ this.isInSubmenu = false;
+ this.subMenuAllItems = [];
+ this.zone.run(() => {
+ this.isOpen.set(false);
+ this.clientRectFn.set(null);
+ this.commandFn = null;
+ this.isLoading.set(false);
+ });
+ }
+
+ /**
+ * Switches the visible menu into a loading/sub-menu state in-place.
+ * Because keepRange items don't call deleteRange, the Tiptap suggestion session
+ * stays alive and keyboard routing continues to work without any extra plumbing.
+ * subMenuAllItems is cleared so filterItems() returns [] during loading,
+ * preventing stale items from leaking through if onUpdate fires before setItems.
+ */
+ openSubmenu(): void {
+ this.isInSubmenu = true;
+ this.subMenuAllItems = [];
+ this.zone.run(() => {
+ // this.items.set([]);
+ // this.activeIndex.set(0);
+ // this.isLoading.set(true);
+ this.commandFn = null;
+ // isOpen and clientRectFn unchanged — menu is already visible and positioned
+ });
+ }
+
+ /** Populates the sub-menu with resolved items and clears the loading state. */
+ setItems(items: BlockItem[], commandFn: (item: BlockItem) => void): void {
+ this.subMenuAllItems = items; // keep master list for re-filtering as user types
+ this.zone.run(() => {
+ this.items.set(items);
+ this.commandFn = commandFn;
+ // this.activeIndex.set(0);
+ // this.isLoading.set(false);
+ });
+ }
+
+ select(item: BlockItem): void {
+ // Clicking the floating menu can blur the editor; ProseMirror may then treat the
+ // caret as outside the `/…` suggestion range, which deactivates @tiptap/suggestion
+ // and fires onExit → close() before this handler runs. Keep the editor focused
+ // so the suggestion session (and our commandFn) stay valid for sub-menu picks.
+ this.editor?.view.focus();
+ this.commandFn?.(item);
+ }
+
+ handleKeyDown(event: KeyboardEvent): boolean {
+ if (!this.isOpen()) return false;
+ const count = this.items().length;
+ switch (event.key) {
+ case 'ArrowDown':
+ this.zone.run(() => this.activeIndex.update((i) => (i + 1) % Math.max(1, count)));
+ return true;
+ case 'ArrowUp':
+ this.zone.run(() =>
+ this.activeIndex.update(
+ (i) => (i - 1 + Math.max(1, count)) % Math.max(1, count)
+ )
+ );
+ return true;
+ case 'Enter':
+ if (count > 0) this.select(this.items()[this.activeIndex()]);
+ return true;
+ case 'Escape':
+ this.close();
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.types.ts b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.types.ts
new file mode 100644
index 000000000000..aa3dad8ec52a
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.types.ts
@@ -0,0 +1,23 @@
+import type { ChainedCommands, Editor } from '@tiptap/core';
+
+export interface BlockItem {
+ label: string;
+ description: string;
+ icon?: string;
+ keywords: string[];
+ /**
+ * When true, the slash trigger text is NOT deleted from the editor on selection.
+ * The Tiptap suggestion session stays alive, so keyboard navigation keeps working.
+ * The item's onSelect is responsible for cleaning up the range later.
+ */
+ keepRange?: boolean;
+ /**
+ * When true, choosing this row only clears the slash trigger and closes the menu
+ * (no document insert / no drill-down). Used for empty and error rows in submenus.
+ */
+ isEmptyState?: boolean;
+ /** Canonical block name used for allowedBlocks filtering. Absent = always shown. */
+ blockName?: string;
+ apply?: (chain: ChainedCommands) => ChainedCommands;
+ onSelect?: (editor: Editor, range?: { from: number; to: number }) => void;
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/toolbar/editor-toolbar-state.service.ts b/core-web/libs/new-block-editor/src/lib/editor/toolbar/editor-toolbar-state.service.ts
new file mode 100644
index 000000000000..658ecafcdd69
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/toolbar/editor-toolbar-state.service.ts
@@ -0,0 +1,69 @@
+import { Injectable, NgZone, inject, signal } from '@angular/core';
+
+import { Editor } from '@tiptap/core';
+
+@Injectable({ providedIn: 'root' })
+export class EditorToolbarStateService {
+ private readonly zone = inject(NgZone);
+
+ readonly isBold = signal(false);
+ readonly isItalic = signal(false);
+ readonly isStrike = signal(false);
+ readonly isCode = signal(false);
+ readonly isBulletList = signal(false);
+ readonly isOrderedList = signal(false);
+ readonly isBlockquote = signal(false);
+ readonly isCodeBlock = signal(false);
+ readonly headingLevel = signal(null);
+ readonly isLink = signal(false);
+ readonly canUndo = signal(false);
+ readonly canRedo = signal(false);
+ readonly canIndent = signal(false);
+ readonly canOutdent = signal(false);
+ readonly isImageSelected = signal(false);
+ readonly imageTextWrap = signal(null);
+
+ connect(editor: Editor): () => void {
+ const update = () => {
+ this.zone.run(() => {
+ this.isBold.set(editor.isActive('bold'));
+ this.isItalic.set(editor.isActive('italic'));
+ this.isStrike.set(editor.isActive('strike'));
+ this.isCode.set(editor.isActive('code'));
+ this.isBulletList.set(editor.isActive('bulletList'));
+ this.isOrderedList.set(editor.isActive('orderedList'));
+ this.isBlockquote.set(editor.isActive('blockquote'));
+ this.isCodeBlock.set(editor.isActive('codeBlock'));
+ this.isLink.set(editor.isActive('link'));
+ this.isImageSelected.set(editor.isActive('image'));
+ this.imageTextWrap.set(
+ editor.isActive('image')
+ ? (editor.getAttributes('image').textWrap ?? null)
+ : null
+ );
+ this.canUndo.set(editor.can().undo());
+ this.canRedo.set(editor.can().redo());
+ this.canIndent.set(editor.can().sinkListItem('listItem'));
+ this.canOutdent.set(editor.can().liftListItem('listItem'));
+
+ let level: number | null = null;
+ for (const l of [1, 2, 3]) {
+ if (editor.isActive('heading', { level: l })) {
+ level = l;
+ break;
+ }
+ }
+ this.headingLevel.set(level);
+ });
+ };
+
+ editor.on('update', update);
+ editor.on('selectionUpdate', update);
+ update();
+
+ return () => {
+ editor.off('update', update);
+ editor.off('selectionUpdate', update);
+ };
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/toolbar/toolbar.component.ts b/core-web/libs/new-block-editor/src/lib/editor/toolbar/toolbar.component.ts
new file mode 100644
index 000000000000..b223173e6030
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/toolbar/toolbar.component.ts
@@ -0,0 +1,669 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ OnDestroy,
+ computed,
+ effect,
+ inject,
+ input,
+ output
+} from '@angular/core';
+
+import { Editor } from '@tiptap/core';
+
+import { EditorToolbarStateService } from './editor-toolbar-state.service';
+
+import { ImageDialogService } from '../components/image/image-dialog.service';
+import { LinkDialogService } from '../components/link/link-dialog.service';
+import { TableDialogService } from '../components/table/table-dialog.service';
+import { VideoDialogService } from '../components/video/video-dialog.service';
+import { EmojiPickerService } from '../emoji-menu/emoji-picker.service';
+
+@Component({
+ selector: 'dot-block-editor-toolbar',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ role: 'toolbar',
+ 'aria-label': 'Text formatting',
+ 'aria-orientation': 'horizontal',
+ class: 'flex flex-wrap items-center gap-0.5 border-b border-gray-200 bg-gray-50 px-2 py-1.5 rounded-t-lg',
+ '(keydown)': 'onToolbarKeyDown($event)'
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (showBlockFormatsGroup()) {
+
+
+
+ @if (isAllowed('bulletList')) {
+
+ }
+ @if (isAllowed('orderedList')) {
+
+ }
+ @if (isAllowed('blockquote')) {
+
+ }
+ @if (isAllowed('codeBlock')) {
+
+ }
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @if (isAllowed('horizontalRule')) {
+
+ }
+
+ @if (showInsertGroup()) {
+
+
+
+ @if (isAllowed('link')) {
+
+ }
+ @if (isAllowed('image')) {
+
+ }
+ @if (isAllowed('video')) {
+
+ }
+ @if (isAllowed('table')) {
+
+ }
+ @if (isAllowed('emoji')) {
+
+ }
+ }
+
+
+
+ `
+})
+export class ToolbarComponent implements OnDestroy {
+ protected readonly state = inject(EditorToolbarStateService);
+ private readonly imageDialogService = inject(ImageDialogService);
+ private readonly linkDialogService = inject(LinkDialogService);
+ private readonly tableDialogService = inject(TableDialogService);
+ private readonly videoDialogService = inject(VideoDialogService);
+ private readonly emojiPickerService = inject(EmojiPickerService);
+
+ readonly editor = input.required();
+ readonly allowedBlocks = input();
+ readonly isFullscreen = input(false);
+ readonly fullscreenToggle = output();
+
+ private cleanupFn: (() => void) | null = null;
+
+ constructor() {
+ effect(() => {
+ this.cleanupFn?.();
+ this.cleanupFn = this.state.connect(this.editor());
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.cleanupFn?.();
+ }
+
+ protected btnClass(active: boolean): string {
+ const base =
+ 'flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed';
+ return active
+ ? `${base} bg-indigo-100 text-indigo-700`
+ : `${base} text-gray-600 hover:bg-gray-100 hover:text-gray-900`;
+ }
+
+ protected readonly blockTypeValue = computed(() => {
+ const level = this.state.headingLevel();
+ return level === null ? 'paragraph' : `h${level}`;
+ });
+
+ // ── allowedBlocks helpers ────────────────────────────────────────────────
+
+ private readonly _allowedSet = computed(() => {
+ const list = this.allowedBlocks();
+ return list ? new Set(list) : null;
+ });
+
+ protected isAllowed(block: string): boolean {
+ const set = this._allowedSet();
+ return !set || set.has(block);
+ }
+
+ protected readonly showBlockFormatsGroup = computed(() => {
+ const s = this._allowedSet();
+ return (
+ !s ||
+ s.has('bulletList') ||
+ s.has('orderedList') ||
+ s.has('blockquote') ||
+ s.has('codeBlock')
+ );
+ });
+
+ protected readonly showInsertGroup = computed(() => {
+ const s = this._allowedSet();
+ return (
+ !s ||
+ s.has('link') ||
+ s.has('image') ||
+ s.has('video') ||
+ s.has('table') ||
+ s.has('emoji')
+ );
+ });
+
+ // ── History ──────────────────────────────────────────────────────────────
+
+ protected undo(): void {
+ this.editor().chain().focus().undo().run();
+ }
+
+ protected redo(): void {
+ this.editor().chain().focus().redo().run();
+ }
+
+ // ── Block type ───────────────────────────────────────────────────────────
+
+ protected setBlockType(event: Event): void {
+ const value = (event.target as HTMLSelectElement).value;
+ const editor = this.editor();
+ if (value === 'paragraph') {
+ editor.chain().focus().setParagraph().run();
+ } else {
+ const level = Number(value.replace('h', '')) as 1 | 2 | 3;
+ editor.chain().focus().setHeading({ level }).run();
+ }
+ }
+
+ // ── Inline marks ─────────────────────────────────────────────────────────
+
+ protected toggleBold(): void {
+ this.editor().chain().focus().toggleBold().run();
+ }
+
+ protected toggleItalic(): void {
+ this.editor().chain().focus().toggleItalic().run();
+ }
+
+ protected toggleStrike(): void {
+ this.editor().chain().focus().toggleStrike().run();
+ }
+
+ protected toggleCode(): void {
+ this.editor().chain().focus().toggleCode().run();
+ }
+
+ // ── Block formats ────────────────────────────────────────────────────────
+
+ protected toggleBulletList(): void {
+ this.editor().chain().focus().toggleBulletList().run();
+ }
+
+ protected toggleOrderedList(): void {
+ this.editor().chain().focus().toggleOrderedList().run();
+ }
+
+ protected toggleBlockquote(): void {
+ this.editor().chain().focus().toggleBlockquote().run();
+ }
+
+ protected toggleCodeBlock(): void {
+ this.editor().chain().focus().toggleCodeBlock().run();
+ }
+
+ protected insertHR(): void {
+ this.editor().chain().focus().setHorizontalRule().run();
+ }
+
+ protected indent(): void {
+ this.editor().chain().focus().sinkListItem('listItem').run();
+ }
+
+ protected outdent(): void {
+ this.editor().chain().focus().liftListItem('listItem').run();
+ }
+
+ protected clearFormat(): void {
+ this.editor().chain().focus().unsetAllMarks().clearNodes().run();
+ }
+
+ // ── Close all dialogs helper (B5) ────────────────────────────────────────
+
+ private closeAllDialogs(): void {
+ this.imageDialogService.close();
+ this.linkDialogService.close();
+ this.videoDialogService.close();
+ this.tableDialogService.close();
+ this.emojiPickerService.close();
+ }
+
+ // ── Dialog openers ────────────────────────────────────────────────────────
+
+ protected openLinkDialog(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.linkDialogService.isOpen()) {
+ this.linkDialogService.close();
+ return;
+ }
+ this.closeAllDialogs();
+ const editor = this.editor();
+ const { from, to, empty } = editor.state.selection;
+ const btn = event.currentTarget as HTMLElement;
+
+ // Check if cursor/selection is inside an existing link
+ const linkMark = editor.state.doc
+ .resolve(from)
+ .marks()
+ .find((m) => m.type.name === 'link');
+ const linkEl = linkMark
+ ? ((editor.view.domAtPos(from).node as HTMLElement).closest?.(
+ 'a[href]'
+ ) as HTMLElement | null)
+ : null;
+
+ if (linkMark && linkEl) {
+ // Edit mode — anchor to the link element itself
+ const href = linkMark.attrs['href'] ?? '';
+ const displayText = linkEl.textContent?.trim() ?? '';
+ const anchorPos = editor.view.posAtDOM(linkEl, 0);
+
+ this.linkDialogService.open(
+ (newHref, newDisplayText, openInNewTab) => {
+ editor
+ .chain()
+ .focus()
+ .setTextSelection(anchorPos)
+ .extendMarkRange('link')
+ .insertContent({
+ type: 'text',
+ text: newDisplayText ?? newHref,
+ marks: [
+ {
+ type: 'link',
+ attrs: { href: newHref, target: openInNewTab ? '_blank' : null }
+ }
+ ]
+ })
+ .run();
+ },
+ () => linkEl.getBoundingClientRect(),
+ { href, displayText, target: linkMark.attrs['target'] ?? null },
+ linkEl
+ );
+ } else {
+ // Insert mode — anchor to the toolbar button
+ const selectedText = empty ? '' : editor.state.doc.textBetween(from, to);
+ this.linkDialogService.open(
+ (href, displayText, openInNewTab) => {
+ editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: 'text',
+ text: displayText ?? href,
+ marks: [
+ {
+ type: 'link',
+ attrs: { href, target: openInNewTab ? '_blank' : null }
+ }
+ ]
+ })
+ .run();
+ },
+ () => btn.getBoundingClientRect(),
+ selectedText ? { href: '', displayText: selectedText } : undefined
+ );
+ }
+ }
+
+ protected openImageDialog(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.imageDialogService.isOpen()) {
+ this.imageDialogService.close();
+ return;
+ }
+ this.closeAllDialogs();
+ const btn = event.currentTarget as HTMLElement;
+ const editor = this.editor();
+ this.imageDialogService.open(
+ (src, title, alt) => {
+ editor
+ .chain()
+ .focus()
+ .setImage({ src, title: title || undefined, alt: alt || undefined })
+ .run();
+ },
+ () => btn.getBoundingClientRect()
+ );
+ }
+
+ protected openVideoDialog(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.videoDialogService.isOpen()) {
+ this.videoDialogService.close();
+ return;
+ }
+ this.closeAllDialogs();
+ const btn = event.currentTarget as HTMLElement;
+ const editor = this.editor();
+ this.videoDialogService.open(
+ (src, title) => {
+ editor
+ .chain()
+ .focus()
+ .insertContent({ type: 'video', attrs: { src, title: title ?? null } })
+ .run();
+ },
+ () => btn.getBoundingClientRect()
+ );
+ }
+
+ protected openTableDialog(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.tableDialogService.isOpen()) {
+ this.tableDialogService.close();
+ return;
+ }
+ this.closeAllDialogs();
+ const btn = event.currentTarget as HTMLElement;
+ const editor = this.editor();
+ this.tableDialogService.open(
+ (config) => {
+ editor.chain().focus().insertTable(config).run();
+ },
+ () => btn.getBoundingClientRect()
+ );
+ }
+
+ protected openEmojiPicker(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.emojiPickerService.isOpen()) {
+ this.emojiPickerService.close();
+ return;
+ }
+ this.closeAllDialogs();
+ const btn = event.currentTarget as HTMLElement;
+ this.emojiPickerService.open(
+ (emoji) => this.editor().chain().focus().insertContent(emoji).run(),
+ () => btn.getBoundingClientRect()
+ );
+ }
+
+ // ── Image text wrap ──────────────────────────────────────────────────────
+
+ protected setImageWrap(value: 'left' | 'right'): void {
+ this.editor().chain().focus().setImageTextWrap(value).run();
+ }
+
+ // ── Edit image properties (F1) ───────────────────────────────────────────
+
+ protected openImagePropertiesDialog(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const editor = this.editor();
+ if (!editor) return;
+
+ const { from } = editor.state.selection;
+ const node = editor.state.doc.nodeAt(from);
+ if (!node || node.type.name !== 'image') return;
+
+ const btn = event.currentTarget as HTMLElement;
+ this.closeAllDialogs();
+ this.imageDialogService.open(
+ (src, title, alt) => {
+ editor
+ .chain()
+ .focus()
+ .updateAttributes('image', {
+ src,
+ title: title || null,
+ alt: alt || null
+ })
+ .run();
+ },
+ () => btn.getBoundingClientRect(),
+ {
+ src: node.attrs['src'],
+ title: node.attrs['title'] ?? '',
+ alt: node.attrs['alt'] ?? ''
+ }
+ );
+ }
+
+ // ── Keyboard navigation (roving tabindex) ────────────────────────────────
+
+ protected onToolbarKeyDown(event: KeyboardEvent): void {
+ if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
+ const els = Array.from(
+ (event.currentTarget as HTMLElement).querySelectorAll(
+ 'button:not([disabled]), select'
+ )
+ );
+ const idx = els.indexOf(document.activeElement as HTMLElement);
+ if (idx === -1) return;
+ event.preventDefault();
+ const next =
+ event.key === 'ArrowRight'
+ ? (idx + 1) % els.length
+ : (idx - 1 + els.length) % els.length;
+ els[next]?.focus();
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/test-setup.ts b/core-web/libs/new-block-editor/src/test-setup.ts
new file mode 100644
index 000000000000..e5d0e9a5aa4b
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/test-setup.ts
@@ -0,0 +1,43 @@
+import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
+
+import { setupResizeObserverMock } from '@dotcms/utils-testing';
+
+setupZoneTestEnv({
+ errorOnUnknownElements: true,
+ errorOnUnknownProperties: true
+});
+
+setupResizeObserverMock();
+
+const originalConsoleError = console.error;
+const jsDomCssError = 'Error: Could not parse CSS stylesheet';
+console.error = (...params) => {
+ if (params.find((p) => p?.toString()?.includes(jsDomCssError))) {
+ return;
+ }
+
+ const hasXmlHttpRequestError = params.some((p) => {
+ if (p && typeof p === 'object') {
+ const errorObj = p as { type?: string; message?: string; name?: string };
+ if (errorObj.type === 'XMLHttpRequest' || errorObj.name === 'AggregateError') {
+ return true;
+ }
+ if (
+ errorObj.message?.includes('XMLHttpRequest') ||
+ (p as Error).stack?.includes('XMLHttpRequest')
+ ) {
+ return true;
+ }
+ }
+ const str = p?.toString() || '';
+ return str.includes('AggregateError') && str.includes('XMLHttpRequest');
+ });
+
+ if (hasXmlHttpRequestError) {
+ return;
+ }
+
+ originalConsoleError(...params);
+};
+
+Element.prototype.scrollIntoView = jest.fn();
diff --git a/core-web/libs/new-block-editor/tsconfig.json b/core-web/libs/new-block-editor/tsconfig.json
new file mode 100644
index 000000000000..e5ca9737c62f
--- /dev/null
+++ b/core-web/libs/new-block-editor/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "useDefineForClassFields": false,
+ "forceConsistentCasingInFileNames": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": false,
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "lib": ["dom", "dom.iterable", "es2022"]
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../tsconfig.base.json",
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/core-web/libs/new-block-editor/tsconfig.lib.json b/core-web/libs/new-block-editor/tsconfig.lib.json
new file mode 100644
index 000000000000..ba8e6019d0ae
--- /dev/null
+++ b/core-web/libs/new-block-editor/tsconfig.lib.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": ["src/test-setup.ts", "src/**/*.spec.ts", "jest.config.ts", "src/**/*.test.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/core-web/libs/new-block-editor/tsconfig.spec.json b/core-web/libs/new-block-editor/tsconfig.spec.json
new file mode 100644
index 000000000000..3ce11eb5dd00
--- /dev/null
+++ b/core-web/libs/new-block-editor/tsconfig.spec.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "preserve",
+ "types": ["jest", "node"],
+ "target": "es2022",
+ "strict": false,
+ "noPropertyAccessFromIndexSignature": false,
+ "moduleResolution": "bundler",
+ "isolatedModules": true
+ },
+ "files": ["src/test-setup.ts"],
+ "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
+}
diff --git a/core-web/package.json b/core-web/package.json
index e7ca8f9cf4d9..f67e5f5475f2 100644
--- a/core-web/package.json
+++ b/core-web/package.json
@@ -60,6 +60,8 @@
"@angular/router": "21.2.1",
"@ctrl/tinycolor": "3.1.7",
"@date-fns/tz": "1.4.1",
+ "@emoji-mart/data": "1.2.1",
+ "@floating-ui/dom": "1.6.13",
"@jitsu/sdk-js": "3.1.5",
"@material/mwc-button": "0.27.0",
"@material/mwc-checkbox": "0.27.0",
@@ -76,31 +78,36 @@
"@nx/playwright": "22.5.4",
"@primeuix/themes": "2.0.3",
"@tailwindcss/postcss": "4.2.1",
+ "@tailwindcss/typography": "^0.5.19",
"@tarekraafat/autocomplete.js": "10.2.9",
"@tinymce/tinymce-angular": "7.0.0",
"@tinymce/tinymce-react": "5.1.1",
- "@tiptap/core": "2.27.2",
- "@tiptap/extension-bubble-menu": "2.27.2",
- "@tiptap/extension-character-count": "2.27.2",
- "@tiptap/extension-collaboration": "2.27.2",
- "@tiptap/extension-drag-handle": "2.27.2",
- "@tiptap/extension-floating-menu": "2.27.2",
- "@tiptap/extension-highlight": "2.27.2",
- "@tiptap/extension-image": "2.27.2",
- "@tiptap/extension-link": "2.27.2",
- "@tiptap/extension-node-range": "2.27.2",
- "@tiptap/extension-placeholder": "2.27.2",
- "@tiptap/extension-subscript": "2.27.2",
- "@tiptap/extension-superscript": "2.27.2",
- "@tiptap/extension-table": "3.0.7",
- "@tiptap/extension-table-cell": "2.27.2",
- "@tiptap/extension-table-header": "2.27.2",
- "@tiptap/extension-table-row": "3.20.0",
- "@tiptap/extension-text-align": "2.27.2",
- "@tiptap/extension-underline": "2.27.2",
- "@tiptap/extension-youtube": "2.27.2",
- "@tiptap/starter-kit": "2.27.2",
- "@tiptap/suggestion": "2.27.2",
+ "@tiptap/core": "3.22.2",
+ "@tiptap/extension-bubble-menu": "3.22.2",
+ "@tiptap/extension-character-count": "3.22.2",
+ "@tiptap/extension-collaboration": "3.22.2",
+ "@tiptap/extension-drag-handle": "3.22.2",
+ "@tiptap/extension-emoji": "3.22.2",
+ "@tiptap/extension-floating-menu": "3.22.2",
+ "@tiptap/extension-highlight": "3.22.2",
+ "@tiptap/extension-image": "3.22.2",
+ "@tiptap/extension-link": "3.22.2",
+ "@tiptap/extension-node-range": "3.22.2",
+ "@tiptap/extension-placeholder": "3.22.2",
+ "@tiptap/extension-subscript": "3.22.2",
+ "@tiptap/extension-superscript": "3.22.2",
+ "@tiptap/extension-table": "3.22.2",
+ "@tiptap/extension-table-cell": "3.22.2",
+ "@tiptap/extension-table-header": "3.22.2",
+ "@tiptap/extension-table-row": "3.22.2",
+ "@tiptap/extension-text-align": "3.22.2",
+ "@tiptap/extension-underline": "3.22.2",
+ "@tiptap/extension-youtube": "3.22.2",
+ "@tiptap/extensions": "3.22.2",
+ "@tiptap/pm": "3.22.2",
+ "@tiptap/starter-kit": "3.22.2",
+ "@tiptap/suggestion": "3.22.2",
+ "@tiptap/y-tiptap": "3.0.3",
"axios": "1.13.6",
"cfonts": "3.3.1",
"chalk": "5.6.2",
@@ -113,6 +120,7 @@
"document-register-element": "1.7.2",
"dom-autoscroller": "2.3.4",
"dragula": "3.7.3",
+ "emoji-mart": "5.6.0",
"execa": "9.6.0",
"font-awesome": "4.7.0",
"fs-extra": "11.3.2",
@@ -126,7 +134,7 @@
"ng-packagr": "19.2.2",
"ng2-dragula": "5.0.1",
"ngx-markdown": "20.1.0",
- "ngx-tiptap": "12.0.0",
+ "ngx-tiptap": "14.0.1",
"node-fetch": "2.6.1",
"ora": "9.0.0",
"primeicons": "7.0.0",
@@ -202,7 +210,6 @@
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@testing-library/react-hooks": "8.0.1",
- "@tiptap/pm": "2.27.2",
"@types/dragula": "3.7.4",
"@types/googlemaps": "3.40.3",
"@types/jest": "30.0.0",
diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json
index 28336d565c77..6ce8321e9a57 100644
--- a/core-web/tsconfig.base.json
+++ b/core-web/tsconfig.base.json
@@ -22,6 +22,8 @@
"@dotcms/angular": ["libs/sdk/angular/src/public_api.ts"],
"@dotcms/app/*": ["apps/dotcms-ui/src/app/*"],
"@dotcms/block-editor": ["libs/block-editor/src/public-api.ts"],
+ "@dotcms/new-block-editor": ["libs/new-block-editor/src/index.ts"],
+ "@dotcms/new-block-editor/*": ["libs/new-block-editor/src/lib/*"],
"@dotcms/client": ["libs/sdk/client/src/index.ts"],
"@dotcms/client/internal": ["libs/sdk/client/src/internal.ts"],
"@dotcms/contenttype-fields": ["libs/contenttype-fields/src/index.ts"],
diff --git a/core-web/yarn.lock b/core-web/yarn.lock
index 71286bc1f154..fa8fac12f23c 100644
--- a/core-web/yarn.lock
+++ b/core-web/yarn.lock
@@ -1888,6 +1888,11 @@
dependencies:
tslib "^2.4.0"
+"@emoji-mart/data@1.2.1":
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
+ integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
+
"@esbuild/aix-ppc64@0.25.12":
version "0.25.12"
resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c"
@@ -2425,6 +2430,34 @@
resolved "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz#5d5e8aee8fce48f5e189bf730ebd1f758f491451"
integrity sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==
+"@floating-ui/core@^1.6.0", "@floating-ui/core@^1.7.5":
+ version "1.7.5"
+ resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz#d4af157a03330af5a60e69da7a4692507ada0622"
+ integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
+ dependencies:
+ "@floating-ui/utils" "^0.2.11"
+
+"@floating-ui/dom@1.6.13":
+ version "1.6.13"
+ resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34"
+ integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==
+ dependencies:
+ "@floating-ui/core" "^1.6.0"
+ "@floating-ui/utils" "^0.2.9"
+
+"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.13":
+ version "1.7.6"
+ resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf"
+ integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==
+ dependencies:
+ "@floating-ui/core" "^1.7.5"
+ "@floating-ui/utils" "^0.2.11"
+
+"@floating-ui/utils@^0.2.11", "@floating-ui/utils@^0.2.9":
+ version "0.2.11"
+ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f"
+ integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
+
"@foliojs-fork/fontkit@^1.9.2":
version "1.9.2"
resolved "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz#94241c195bc6204157bc84c33f34bdc967eca9c3"
@@ -6052,11 +6085,6 @@
resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
-"@popperjs/core@^2.9.0":
- version "2.11.8"
- resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
- integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
-
"@primeuix/motion@^0.0.10":
version "0.0.10"
resolved "https://registry.npmjs.org/@primeuix/motion/-/motion-0.0.10.tgz#9af4238226042d80518dd343c6481d03582e374a"
@@ -7293,6 +7321,13 @@
postcss "^8.5.6"
tailwindcss "4.2.1"
+"@tailwindcss/typography@^0.5.19":
+ version "0.5.19"
+ resolved "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz#ecb734af2569681eb40932f09f60c2848b909456"
+ integrity sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==
+ dependencies:
+ postcss-selector-parser "6.0.10"
+
"@tarekraafat/autocomplete.js@10.2.9":
version "10.2.9"
resolved "https://registry.npmjs.org/@tarekraafat/autocomplete.js/-/autocomplete.js-10.2.9.tgz#316b2b1f8171f21737fdcbadda74c2cfae00f840"
@@ -7390,213 +7425,230 @@
prop-types "^15.6.2"
tinymce "^7.0.0 || ^6.0.0 || ^5.5.1"
-"@tiptap/core@2.27.2", "@tiptap/core@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz#679eef9ce673d7243ce28d303852a98cbd1844be"
- integrity sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==
-
-"@tiptap/extension-blockquote@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz#af5fccec360cd94b9d3d8751c868d92e9e70907d"
- integrity sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==
-
-"@tiptap/extension-bold@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz#612104c1e9eaba4c9301b21daa7ef19a9e487051"
- integrity sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==
-
-"@tiptap/extension-bubble-menu@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz#f75eb12a8d2496bcde739b5c20684db635a48b9e"
- integrity sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==
- dependencies:
- tippy.js "^6.3.7"
-
-"@tiptap/extension-bullet-list@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz#2347683ab898471ab7df2c3e63b20e8d3d7c46f3"
- integrity sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==
-
-"@tiptap/extension-character-count@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.27.2.tgz#b8d8f1b0934a0ec383751263c2e039c1f4c99cb0"
- integrity sha512-EcQRIvbLbMDDzo7uFqXYgh1CfgedS9sYX4BllktY2OlXLPdNpwo9t8WMK/a7soESNv0Le3WZ5pNvnNhv7Z2YdA==
-
-"@tiptap/extension-code-block@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz#0a622d5bf92c9db55e9f5eaba1a6a8d7a015b1f1"
- integrity sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==
-
-"@tiptap/extension-code@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz#bfbaf07f67232144c6865ffbea20896e02c6fe6f"
- integrity sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==
-
-"@tiptap/extension-collaboration@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-2.27.2.tgz#9711e06f76b11d75a41f637e95a919f556b8eaff"
- integrity sha512-Y61ItHxQ1uc/Ir27mBQRI/wY9JkOui194V+awNv+1YHeaKArTjC2cdSvNzj9+h8JIh5MyfvslSf8hBa7t7PzAg==
-
-"@tiptap/extension-document@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz#697ee04c03c7b37bc37d942d60fcc5fa304988b5"
- integrity sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==
-
-"@tiptap/extension-drag-handle@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-2.27.2.tgz#41e664f1f4afc74f1d8c75460fcdd8ae8624d087"
- integrity sha512-T4evSv5SYaJPvQ9vY2165KgerVKhSmE4aadtWaFqf+tMFvtbGJ4jArch1a0Wus2MYtI30cFCWYWfRegwDO9/+A==
- dependencies:
- tippy.js "^6.3.7"
-
-"@tiptap/extension-dropcursor@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz#c0f62e32a6c7bc7dc8cc6b6edd84d9173bc1db16"
- integrity sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==
-
-"@tiptap/extension-floating-menu@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz#b04e8f542d3900db1d845a03a0f5ab079a06daaf"
- integrity sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==
- dependencies:
- tippy.js "^6.3.7"
-
-"@tiptap/extension-gapcursor@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz#2e82dd87cb2dfcca90f0abb3b43f1f6748a54e2c"
- integrity sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==
-
-"@tiptap/extension-hard-break@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz#250200feb316cfb40ed8e9188ee6684c2811b475"
- integrity sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==
-
-"@tiptap/extension-heading@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz#10afd812475c6a3f62a26bd1975998bfa94cb9fb"
- integrity sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==
-
-"@tiptap/extension-highlight@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.2.tgz#5647a82ac2e1c04532e0d8dbc15946f58d6151ae"
- integrity sha512-ZjlktDdMjruMJFAVz0TbQf0v92Jqkc7Ri1iZJqBXuLid+r+GxUzl2CVAV7qq5yagkGQgvAG+WGsMk880HgR3MA==
-
-"@tiptap/extension-history@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz#43c6d976c521dc1cf2d4a0707df7d8328be0e9a9"
- integrity sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==
-
-"@tiptap/extension-horizontal-rule@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz#7440adb913dfe270577d1853cfc2f725f36e0040"
- integrity sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==
-
-"@tiptap/extension-image@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz#c962eaae3d390e1641cffacdbd61af613306c32c"
- integrity sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==
-
-"@tiptap/extension-italic@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz#91b6ded7b84ed218a8c07ed979332d0dbf923d2b"
- integrity sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==
-
-"@tiptap/extension-link@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz#f250b6119b02f836e0746af4c28766b643b78f6c"
- integrity sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==
+"@tiptap/core@3.22.2", "@tiptap/core@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.22.2.tgz#2352ffa67bfa1a3528898524d13ba9bde5c74b37"
+ integrity sha512-atq35NkpeEphH6vNYJ0pTLLBA73FAbvTV9Ovd3AaTC5s99/KF5Q86zVJXvml8xPRcMGM6dLp+eSSd06oTscMSA==
+
+"@tiptap/extension-blockquote@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.2.tgz#bfa2db6f9d65bd411a74ca5f3610f5094adc322e"
+ integrity sha512-iTdlmGFcgxi4LKaOW2Rc9/yD83qTXgRm5BN3vCHWy5+TbEnReYxYqU5qKsbtTbKy30sO8TJTdAXTZ29uomShQQ==
+
+"@tiptap/extension-bold@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.2.tgz#980484072b2f45cb8794869283af67017cefcc1a"
+ integrity sha512-bqsPJyKcT/RWse4e16U2EKhraR8a2+98TUuk1amG3yCyFJZStoO/j+pN0IqZdZZjr3WtxFyvwWp7Kc59UN+jUA==
+
+"@tiptap/extension-bubble-menu@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.2.tgz#3945c6cc7b403b732aa590debf79bbfa2a0d5f50"
+ integrity sha512-5hbyDOSkJwA2uh0v9Mm0Dd9bb9inx6tHBEDSH2tCB9Rm23poz3yOreB7SNX8xDMe5L0/PQesfWC14RitcmhKPg==
+ dependencies:
+ "@floating-ui/dom" "^1.0.0"
+
+"@tiptap/extension-bullet-list@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.2.tgz#b3dc949be2600a6692363038aeca71ae38ce4e4e"
+ integrity sha512-llrTJnA72RGcWLLO+ro0QN4sjHynhaCerhpV+GZE/ATd8BqV/ekQFdBLJrvC/09My2XQfCwLsyCh92NPXUdELA==
+
+"@tiptap/extension-character-count@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.22.2.tgz#7cda98d43b49bfd7573d20aa37c73893a4f94ca4"
+ integrity sha512-EBTVHbRkv5IhoO/TAij4ivZ2RD2u6aiZHtWuhHVbsVsHfoxSi7YGjVNFv/DnT/BrEwpNy3u/SI/Xo57120HTOA==
+
+"@tiptap/extension-code-block@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.2.tgz#e8c9827e9fe817ac55a9e280f7b86f54dd4f8473"
+ integrity sha512-PEwFlDyvtKF19WCrOFg77qJV9WqhvjCY4ZoXlHP9Hx0KTcOA8W39mtw8d4NWU5pLRK94yHKF1DVVL8UUkEOnww==
+
+"@tiptap/extension-code@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.2.tgz#ad59ddd20feef71fcfbff7c4e2389f7111a2f4a6"
+ integrity sha512-iYFY+yzfYA9MKt7nupyW/PzqL9XC2D0mC8l1z2Y10i0/fGL8NbqIYjhNUAyXGqH3QWcI+DirI66842y2OadPOg==
+
+"@tiptap/extension-collaboration@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.22.2.tgz#e854dccb99b46e64e356bf1d05ebae290045753d"
+ integrity sha512-+viAk2EVoYgJEmJpvnT1NBCK+intvwHEMp7T7luYffkQz8irGKF/7YcgauXp5NBLPTsnIzDWQuY571mo8XMcKg==
+
+"@tiptap/extension-document@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.2.tgz#8567a2df5a0e7b32cb350f90849a8a9ada82bbe5"
+ integrity sha512-yPw9pQeVC4QDh86TuyKCZxxM4g0NAw7mEtGnAo6EpxaBQr1wyBr9yFpys+QTsQpRTmyTf1VHp4iTTLuWHMljIw==
+
+"@tiptap/extension-drag-handle@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.22.2.tgz#aeafa09211c00f61c2a06b5656538701273ccaeb"
+ integrity sha512-9L2krYNe+ZxI7hULAuxE0i9wKMxL8eIoiH866hrOenb2C8PySQLWy/BjWwu3Z6fBFwCG+29wiMeRL7WE128oxg==
+ dependencies:
+ "@floating-ui/dom" "^1.6.13"
+
+"@tiptap/extension-dropcursor@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.2.tgz#79a74011eb03df1f6057fca93fe359286d51ad03"
+ integrity sha512-sDv3fv4LtX0X4nqwh9Gn3C/aZXT+C2JlK7tJovPOpaYP/a6hr03Sn35X5moAfgMCSiWFygEvlTriqwmCsJuxog==
+
+"@tiptap/extension-emoji@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-emoji/-/extension-emoji-3.22.2.tgz#339ec74573d38a56367485e4640f9b69aa19133f"
+ integrity sha512-XvuJdV8XMu9of5LfvpFmZZtSbHEbFxAkxNd07vAjxD6AXJiuSMH6stDnfjsSAd5tSoKjDwPoilmvh83Y+8kIcQ==
+ dependencies:
+ emoji-regex "^10.6.0"
+ emojibase-data "^17"
+ is-emoji-supported "^0.0.5"
+
+"@tiptap/extension-floating-menu@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.2.tgz#345b8ad14aa8130c80ccb016a35b50f0f4071ecc"
+ integrity sha512-r0ZTeh9rNtj9Api+G0YyaB+tAKPDn7aYWg+qSrmAC5EyUPee6Zjn3zlw0q4renCeQflvNRK20xHM8zokC41jOA==
+
+"@tiptap/extension-gapcursor@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.2.tgz#b8ec46740dc6b5060abde6b4359410d36602583b"
+ integrity sha512-rR2OLrl/k2kj7xehaZHq0Y7T+1wy2DOTabir9LsTrktTFEcklrh9qY1KC6rEBkwMKaWrmignR1l39kS6RlKFNw==
+
+"@tiptap/extension-hard-break@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.2.tgz#d1fc488660b33d76b8773bfea98265939e670b95"
+ integrity sha512-ChsoqF4XRp6EWatTRlXL4LMFh/ggwRVCyt09brSfjJV5knFaXlECSa5/+rKLMLMULaj6dVlJqoAD15exgu2HHA==
+
+"@tiptap/extension-heading@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.2.tgz#b4404f040c10f2de17ed4ed7b1e338de4b5e3c2f"
+ integrity sha512-QPHLef+ikAyf7RVc4EdGeKxH4OEGb3ueCEwJ41RcYPtZ1BX9ueei7FC936guTdL1U7w3vQ65qfy86HznzkYgvw==
+
+"@tiptap/extension-highlight@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.22.2.tgz#81fffa7f41eeb13b6cb11583d31aeb0682e47da6"
+ integrity sha512-ecJ5HnCSlUW65xZlqkqz0nN8yhGzp+91HIPKjafPurV4jseUy1O77FthQ6KiZBQFipeqN04tkqEiFt918ydWUQ==
+
+"@tiptap/extension-horizontal-rule@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.2.tgz#01a299823c07df99d9f8045d6b9ac2209fb3d0c0"
+ integrity sha512-Oz8KN5KJAWV1mFNE9UIWXdMD6xa5zPf/0yLsT8V4sgaRm+VsdFKllN58BY9qCZf/kIZbaOez5KkaoeAcm0MAZg==
+
+"@tiptap/extension-image@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.22.2.tgz#f34f57963bfe18130ea9cc6ce2428811764a94e9"
+ integrity sha512-xFCgwreF6sn5mQ/hFDQKn41NIbbfks/Ou9j763Djf3pWsastgzdgwifQOpXVI3aSsqlKUO3o8/8R/yQczvZcwg==
+
+"@tiptap/extension-italic@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.2.tgz#3631598c4a0ae357f81774d83aad6b09a25d9072"
+ integrity sha512-fmtQu2HDnV3sOZPdz0+1lOLI7UtrIhusohJj2UwOLQxG8qqhLwbvWx2OQTlfblgY0z+CjLRr6ANbNDxOTIblfg==
+
+"@tiptap/extension-link@3.22.2", "@tiptap/extension-link@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.2.tgz#3477af30aa558b9efc14dbb95ea3901c9f61f94c"
+ integrity sha512-TXfSoKmng5pecvQUZqdsx6ICeob5V5hhYOj2vCEtjfcjWsyCndqFIl1w+Nt/yI5ehrFNOVPyj3ZvcELuuAW6pw==
dependencies:
linkifyjs "^4.3.2"
-"@tiptap/extension-list-item@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz#562a8a5f56ed7ac70cd4fab37d7fbcd29e9dc078"
- integrity sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==
-
-"@tiptap/extension-node-range@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-2.27.2.tgz#f2c89a39bb22eac8fa207a0cde1b054ac3b68e69"
- integrity sha512-FaoXy99sTMpDlkxKkaeay7DS3AWLPaxAEof/PYvSNziEtbgfNUtOxIybv0o4Enh/hjKc1IDE1Ujt6J1ewue/6A==
-
-"@tiptap/extension-ordered-list@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz#12f2c4309512429a0c21863e741db00356573a4b"
- integrity sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==
-
-"@tiptap/extension-paragraph@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz#e6873c16993bf21b831ecac41bbd137dc5945eb4"
- integrity sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==
-
-"@tiptap/extension-placeholder@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz#5ac421cbc0bb2bf5909e3dcc9a61fec19cab0c53"
- integrity sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==
-
-"@tiptap/extension-strike@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz#9291f6dd9bcf00e1c2b7e043f9d9b18cf35f1db1"
- integrity sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==
-
-"@tiptap/extension-subscript@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-2.27.2.tgz#3d214508d6606a93e134bce6f312f4a6ecc935bd"
- integrity sha512-x2Oz7hrI4KvzzB9pWChFRm6JnKdYAUQDyrlSROngtzXT7VpNQNoD5s8OlICzDeNsaRKzhR8omIz2z17S1VB48g==
-
-"@tiptap/extension-superscript@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-2.27.2.tgz#f6245ca0e80a42693a0b54b92317948fbe326752"
- integrity sha512-VTGJDuNqdesibSVW94Q71VaGVGr/bwBppdaNLn7k6beOegALfIH7ncArlkD/eihOlJ2qaWiT7FoWNLTb/Fdv1w==
-
-"@tiptap/extension-table-cell@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz#ff7a7b854bd7536e81345813cef1c3f38f2eff55"
- integrity sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==
-
-"@tiptap/extension-table-header@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz#9b006bb4cc0a1b8e6038803e9ab2533273535a19"
- integrity sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==
-
-"@tiptap/extension-table-row@3.20.0":
- version "3.20.0"
- resolved "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.20.0.tgz#d92596e6bd16c0230a6e45ffd7527553d7290236"
- integrity sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A==
-
-"@tiptap/extension-table@3.0.7":
- version "3.0.7"
- resolved "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.0.7.tgz#e25c91d4bfb0151fcaa8810057d33382dd3b2f46"
- integrity sha512-S4tvIgagzWnvXLHfltXucgS9TlBwPcQTjQR4llbxmKHAQM4+e77+NGcXXDcQ7E1TdAp3Tk8xRGerGIP7kjCFRA==
-
-"@tiptap/extension-text-align@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz#ce29f871526d32502bd9f3292b84a57d76d66e60"
- integrity sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==
-
-"@tiptap/extension-text-style@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz#5f27d512e8421b5160be37aab17c47dde88a8bea"
- integrity sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==
-
-"@tiptap/extension-text@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz#8b387a95cef4adb112bfb1ed00a8bc50d9204476"
- integrity sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==
-
-"@tiptap/extension-underline@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz#10dd1cbaf3dcd1276b2b0988518f19736f394c22"
- integrity sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==
-
-"@tiptap/extension-youtube@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-2.27.2.tgz#258a0b44f3c2a0b2cf5bdb90b5fe20b9e5d3fdd7"
- integrity sha512-3l/tfJ8wO8/tALo1tpAfN7TTJQQ00V52XaYamjQPVzPGelm/ECCfSCGQ4oRv8gbyzjUbZkNpkSV1Bj2V7QcGDg==
-
-"@tiptap/pm@2.27.2", "@tiptap/pm@^2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz#2e8b187df66eea54702cfba9820800c8d10c21ef"
- integrity sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==
+"@tiptap/extension-list-item@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.2.tgz#91ac0771858ef3cf1aacaedb39eb403640922f0f"
+ integrity sha512-Mk+iiLIFh8Pfuarr6mWfTO7QJbd2ZQd0nGNhNWXlGAO7DJCb4BP9nj4bEIJ17SbcykGRjsi4WMqY50z4MHXqKQ==
+
+"@tiptap/extension-list-keymap@^3.22.2":
+ version "3.22.3"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.3.tgz#3cd3ae50d9fc2cfc5b6cc48b46d503d58a61cbe2"
+ integrity sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==
+
+"@tiptap/extension-list@^3.22.2":
+ version "3.22.3"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz#8df343dbfa6404c79394795bc5c82c658f889ec0"
+ integrity sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==
+
+"@tiptap/extension-node-range@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.22.2.tgz#68360d681f60943101e470d2a0bba3fe776de951"
+ integrity sha512-hipsIUXrU9RUcc32BLJ/mtfiCtgV35oMTMxEJTJWxJhebEw0iWd7L6cLwHbKui6HgH4W82Zo1s1Ia0Owq3Nu8w==
+
+"@tiptap/extension-ordered-list@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.2.tgz#664585d4fac27439a03257db9db5aebbcdc9cc53"
+ integrity sha512-K7qxoBKmsVkAd3kW64ZRCUPFrDcNGpXRDUBx9YgAO/bTfsfxtH2oil+igsUWGXPczpP4yoHPKjTfhpBpLjGl6Q==
+
+"@tiptap/extension-paragraph@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.2.tgz#38ba161093094860dff9be3dbd5c565c5cb2eb70"
+ integrity sha512-EHZZzxVhvzEPDPWtRBF1YKhB+WCUjd1C2NhjHfL3Dl71PBqM3ZWA6qN7NDGPyNyGGWauui/NR/4X+5AfPqlHyA==
+
+"@tiptap/extension-placeholder@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.22.2.tgz#de9dfac51fd97a5ab2c036b72a4bd9e474464c26"
+ integrity sha512-xYw733CmSeG7MyYBDdV5NFiwlBdXXzw4Mvjb2t4QRXagkDbHeNY/LtKTcrtcMNfO4Jx0mwivGQZUIEC8oAfvxg==
+
+"@tiptap/extension-strike@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.2.tgz#e6ca0685355df5ed443ac3641f857867a0472f6d"
+ integrity sha512-YFC3elKU1L8PiGbcB6tqd/7vWPF5IbydJz0POJpHzSjstX+VfT8VsvS7ubxVuSIWQ11kGkH3mzX6LX8JHsHZxg==
+
+"@tiptap/extension-subscript@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-3.22.2.tgz#4d08288c911b07dc92dd6294f4c4b4e2bef0ff02"
+ integrity sha512-J1wkSlbk7LTE9QRRFDtrIARST2TR9PFl7SIjXxxJwtBdBAJBqRYmioG4m44cFbbmwHDBLOoSs3JTb95Sx+OiAQ==
+
+"@tiptap/extension-superscript@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-3.22.2.tgz#11ef908dd2a7dd1893e22a1f7cd5b219458ad2db"
+ integrity sha512-TNMqn/0EGjRKPooCRq7uBBwk0Khj+AmSfJ/7+GC/QlvHOgL8/tpgisLOqPih9dMdp5YNTLlpdeI6SkA1VikBEw==
+
+"@tiptap/extension-table-cell@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.2.tgz#126b93ee53ec7161df308d7bb9b43967e80f5b4b"
+ integrity sha512-3+YUNtZRHrl6jqQ/RyoGq9iSdXVKwUw3awgu/ogdUvaanXLyESrncbWsEiRzo98PDa4m6hFvjFZ5yhw3cXEhGQ==
+
+"@tiptap/extension-table-header@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.2.tgz#4edf4026f4bd6bb67ee391ddafdb3da492e9e559"
+ integrity sha512-tVqbgl+it314/zzziKuOyRk2O1qptqiclYOfZKl0+ir5pgsVrUczujxzkDAPe4DPEZm/mSjWlsaYpF5OBQU0ng==
+
+"@tiptap/extension-table-row@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.2.tgz#ffc068fb32d447584cfc96fd8bfc309580505422"
+ integrity sha512-n2IDQhThOwRU+vxYj3aGYp66P45r3lgBkWBCGFPLFSL8bx/7p7ZifEtzsk6FOmzNa/GzgKT0lq2RvWVILq/rLA==
+
+"@tiptap/extension-table@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.2.tgz#14b981f146623b9c787908efa1c1d571d7876087"
+ integrity sha512-J9fVsboNRgmdbCVxWl+zlm5FKHmx6TnUHAb+7yt6Fum9lqy1/TwEfP3N7DAF3v7qpkIniVlU3X9ERmiiTAWxSA==
+
+"@tiptap/extension-text-align@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.22.2.tgz#eac2e1906cf61b1777eb5c82555930b0d676762d"
+ integrity sha512-pgqyXzVHo4WmDhK26rDwhK2lxQwnjl/9DP816C2k3To/fZRK1eW7q0pSAYteHWmKkaYAxwj/0UvCU0nXKlPujw==
+
+"@tiptap/extension-text@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.2.tgz#a93a0ba750196060c07a96ded678866b427223ce"
+ integrity sha512-J1w7JwijfSD7ah0WfiwZ/DVWCIGT9x369RM4RJc57i44mIBElj7tl1dh+N5KPGOXKUup4gr7sSJAE38lgeaDMg==
+
+"@tiptap/extension-underline@3.22.2", "@tiptap/extension-underline@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.2.tgz#eedd60cdf25b4e60343ee294bb79268621779557"
+ integrity sha512-BaV6WOowxdkGTLWiU7DdZ3Twh633O4RGqwUM5dDas5LvaqL8AMWGTO8Wg9yAaaKXzd9MtKI1ZCqS/+MtzusgkQ==
+
+"@tiptap/extension-youtube@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-3.22.2.tgz#45f5726ad9122e3bbcb6788dadc5f35d8f2892a9"
+ integrity sha512-wSLswwaLW+LWxe1/PtKzALeeAUS+LGLJfwFJHYTyc+EkqqpQSi2PhDwFx8m9+ADmb8UvjF2Hsg3cha1KrFAJEg==
+
+"@tiptap/extensions@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.2.tgz#9bae5ee6f6b426df38dbdf314cce7a5bec652cc2"
+ integrity sha512-s7MZmm2Xdq+8feIXgY3v7gVpQ5ClqBZi20KheouS7KSbBlrY4fu2irYR1EGc6r1UUVaHMxEa+cx5knhx+mIPUw==
+
+"@tiptap/extensions@^3.22.2":
+ version "3.22.3"
+ resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz#d37c36feec7b2e982f9e1c38781bbc2c3829f131"
+ integrity sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==
+
+"@tiptap/pm@3.22.2", "@tiptap/pm@^3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.2.tgz#4866e1a14a0ba5e354d855d60f19960fd32d4194"
+ integrity sha512-G2ENwIazoSKkAnN5MN5yN91TIZNFm6TxB74kPf3Empr2k9W51Hkcier70jHGpArhgcEaL4BVreuU1PRDRwCeGw==
dependencies:
prosemirror-changeset "^2.3.0"
prosemirror-collab "^1.3.1"
@@ -7608,46 +7660,56 @@
prosemirror-keymap "^1.2.2"
prosemirror-markdown "^1.13.1"
prosemirror-menu "^1.2.4"
- prosemirror-model "^1.23.0"
+ prosemirror-model "^1.24.1"
prosemirror-schema-basic "^1.2.3"
- prosemirror-schema-list "^1.4.1"
+ prosemirror-schema-list "^1.5.0"
prosemirror-state "^1.4.3"
prosemirror-tables "^1.6.4"
prosemirror-trailing-node "^3.0.0"
prosemirror-transform "^1.10.2"
- prosemirror-view "^1.37.0"
-
-"@tiptap/starter-kit@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz#8cad96757376109ce9028c0dc2e941778e5051e9"
- integrity sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==
- dependencies:
- "@tiptap/core" "^2.27.2"
- "@tiptap/extension-blockquote" "^2.27.2"
- "@tiptap/extension-bold" "^2.27.2"
- "@tiptap/extension-bullet-list" "^2.27.2"
- "@tiptap/extension-code" "^2.27.2"
- "@tiptap/extension-code-block" "^2.27.2"
- "@tiptap/extension-document" "^2.27.2"
- "@tiptap/extension-dropcursor" "^2.27.2"
- "@tiptap/extension-gapcursor" "^2.27.2"
- "@tiptap/extension-hard-break" "^2.27.2"
- "@tiptap/extension-heading" "^2.27.2"
- "@tiptap/extension-history" "^2.27.2"
- "@tiptap/extension-horizontal-rule" "^2.27.2"
- "@tiptap/extension-italic" "^2.27.2"
- "@tiptap/extension-list-item" "^2.27.2"
- "@tiptap/extension-ordered-list" "^2.27.2"
- "@tiptap/extension-paragraph" "^2.27.2"
- "@tiptap/extension-strike" "^2.27.2"
- "@tiptap/extension-text" "^2.27.2"
- "@tiptap/extension-text-style" "^2.27.2"
- "@tiptap/pm" "^2.27.2"
-
-"@tiptap/suggestion@2.27.2":
- version "2.27.2"
- resolved "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.2.tgz#901c1bbb5f12002cfe78a1ad40577727c23c374e"
- integrity sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw==
+ prosemirror-view "^1.38.1"
+
+"@tiptap/starter-kit@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.2.tgz#165f17d7f13a81a59b1798bab5093b5520ef30a2"
+ integrity sha512-+CCKX8tOQ/ZPb2k/z6em4AQCFYAcdd8+0TOzPWiuLxRyCHRPBBVhnPsXOKgKwE4OO3E8BsezquuYRYRwsyzCqg==
+ dependencies:
+ "@tiptap/core" "^3.22.2"
+ "@tiptap/extension-blockquote" "^3.22.2"
+ "@tiptap/extension-bold" "^3.22.2"
+ "@tiptap/extension-bullet-list" "^3.22.2"
+ "@tiptap/extension-code" "^3.22.2"
+ "@tiptap/extension-code-block" "^3.22.2"
+ "@tiptap/extension-document" "^3.22.2"
+ "@tiptap/extension-dropcursor" "^3.22.2"
+ "@tiptap/extension-gapcursor" "^3.22.2"
+ "@tiptap/extension-hard-break" "^3.22.2"
+ "@tiptap/extension-heading" "^3.22.2"
+ "@tiptap/extension-horizontal-rule" "^3.22.2"
+ "@tiptap/extension-italic" "^3.22.2"
+ "@tiptap/extension-link" "^3.22.2"
+ "@tiptap/extension-list" "^3.22.2"
+ "@tiptap/extension-list-item" "^3.22.2"
+ "@tiptap/extension-list-keymap" "^3.22.2"
+ "@tiptap/extension-ordered-list" "^3.22.2"
+ "@tiptap/extension-paragraph" "^3.22.2"
+ "@tiptap/extension-strike" "^3.22.2"
+ "@tiptap/extension-text" "^3.22.2"
+ "@tiptap/extension-underline" "^3.22.2"
+ "@tiptap/extensions" "^3.22.2"
+ "@tiptap/pm" "^3.22.2"
+
+"@tiptap/suggestion@3.22.2":
+ version "3.22.2"
+ resolved "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.2.tgz#8fbe1e19d4842c6b29cb6d4d9208cd16ebcc9744"
+ integrity sha512-t2GQSrF4eQyPb+KqXVfcC2cokYIDNfpLLq7B0ELlnWBJURnLOVJ2ssJ6ASI247scu9ZKPG1g5bFP4IXdBhyPgg==
+
+"@tiptap/y-tiptap@3.0.3":
+ version "3.0.3"
+ resolved "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.3.tgz#9e20aeab9ca5254ed8f5c40ad4e06d6997e87588"
+ integrity sha512-8UvuV4lTisCE9cMTc/X8kRyTn9edUO7Kball0I6wb17VwZSjNDfh/YKtP4O5vcPawEzFHQIvZGq/k1h37kAf0w==
+ dependencies:
+ lib0 "^0.2.100"
"@tootallnate/once@2":
version "2.0.0"
@@ -12005,7 +12067,12 @@ emittery@^0.13.1:
resolved "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
-emoji-regex@^10.3.0:
+emoji-mart@5.6.0:
+ version "5.6.0"
+ resolved "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
+ integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==
+
+emoji-regex@^10.3.0, emoji-regex@^10.6.0:
version "10.6.0"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
@@ -12025,6 +12092,11 @@ emoji-regex@^9.2.2:
resolved "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-9.0.1.tgz#b3da51a4d9b1e89608b6a8506a5df6dbc3125495"
integrity sha512-sMMNqKNLVHXJfIKoPbrRJwtYuysVNC9GlKetr72zE3SSVbHqoeDLWVrxP0uM0AE0qvdl3hbUk+tJhhwXZrDHaw==
+emojibase-data@^17:
+ version "17.0.0"
+ resolved "https://registry.npmjs.org/emojibase-data/-/emojibase-data-17.0.0.tgz#5816fba6395da6b567fbd54b029ca6b5de2d9255"
+ integrity sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==
+
emojis-list@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
@@ -14427,6 +14499,11 @@ is-docker@^3.0.0:
resolved "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200"
integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==
+is-emoji-supported@^0.0.5:
+ version "0.0.5"
+ resolved "https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz#f22301b22c63d6322935e829f39dfa59d03a7fe2"
+ integrity sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==
+
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -15734,7 +15811,7 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
-lib0@^0.2.28, lib0@^0.2.42, lib0@^0.2.49:
+lib0@^0.2.100, lib0@^0.2.28, lib0@^0.2.42, lib0@^0.2.49:
version "0.2.117"
resolved "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716"
integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==
@@ -16802,10 +16879,10 @@ ngx-markdown@20.1.0:
mermaid ">= 10.6.0 < 12.0.0"
prismjs "^1.30.0"
-ngx-tiptap@12.0.0:
- version "12.0.0"
- resolved "https://registry.npmjs.org/ngx-tiptap/-/ngx-tiptap-12.0.0.tgz#4a142d2bd85c1c7b154ddb0efd7a2057814b86e7"
- integrity sha512-m8jngTbQZWCMDSogwMcXaNpIpNvCx47D6hWM6lgmOLR2UC5vvvphQmDuC1t66Cn4brdol/vUVVFBbgqo56Jsmw==
+ngx-tiptap@14.0.1:
+ version "14.0.1"
+ resolved "https://registry.npmjs.org/ngx-tiptap/-/ngx-tiptap-14.0.1.tgz#7f88552cd4d96e8a0f68253587908ccddf592534"
+ integrity sha512-LOd8y+8H09Oi6jAE/UWy2sg68Mk5ZGTnPhnq7qeiDhU/QSZWD+SJ5vWkF4sguOCsHtPfde010xBf/zhy/b1P5A==
dependencies:
tslib "^2.3.0"
@@ -18407,6 +18484,14 @@ postcss-safe-parser@^7.0.1:
resolved "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz#36e4f7e608111a0ca940fd9712ce034718c40ec0"
integrity sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==
+postcss-selector-parser@6.0.10:
+ version "6.0.10"
+ resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
+ integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.16, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9:
version "6.1.2"
resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
@@ -18694,7 +18779,7 @@ prosemirror-menu@^1.2.4:
prosemirror-history "^1.0.0"
prosemirror-state "^1.0.0"
-prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4:
+prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4:
version "1.25.4"
resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c"
integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==
@@ -18708,7 +18793,7 @@ prosemirror-schema-basic@^1.2.3:
dependencies:
prosemirror-model "^1.25.0"
-prosemirror-schema-list@^1.4.1:
+prosemirror-schema-list@^1.5.0:
version "1.5.1"
resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5"
integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
@@ -18752,7 +18837,7 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
dependencies:
prosemirror-model "^1.21.0"
-prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.41.4:
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.41.4:
version "1.41.6"
resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz#949d0407a91e36f6024db2191b8d3058dfd18838"
integrity sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==
@@ -18761,6 +18846,15 @@ prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, pros
prosemirror-state "^1.0.0"
prosemirror-transform "^1.1.0"
+prosemirror-view@^1.38.1:
+ version "1.41.8"
+ resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz#bfb48d9dc328f1aa2a0eea1600b0828818be03f1"
+ integrity sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==
+ dependencies:
+ prosemirror-model "^1.20.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
proxy-addr@^2.0.7, proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -20336,7 +20430,16 @@ string-length@^4.0.2:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
-"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -20453,7 +20556,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -20842,13 +20952,6 @@ tinyspy@^4.0.3:
resolved "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78"
integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==
-tippy.js@^6.3.7:
- version "6.3.7"
- resolved "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
- integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
- dependencies:
- "@popperjs/core" "^2.9.0"
-
tldts-core@^7.0.25:
version "7.0.25"
resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz#eaee57facdfb5528383d961f5586d49784519de5"
@@ -22124,7 +22227,7 @@ wordwrap@^1.0.0:
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -22142,6 +22245,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
+wrap-ansi@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"