From 6d1599af672611f215e0a9ddedbf2d9b435509ca Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 25 Mar 2026 21:55:29 -0400 Subject: [PATCH 01/51] feat(new-block-editor): add new block editor library with initial configuration and setup --- core-web/libs/new-block-editor/.eslintrc.json | 38 ++++++++++++++++ core-web/libs/new-block-editor/jest.config.ts | 25 +++++++++++ core-web/libs/new-block-editor/project.json | 23 ++++++++++ core-web/libs/new-block-editor/src/index.ts | 5 +++ .../libs/new-block-editor/src/test-setup.ts | 43 +++++++++++++++++++ core-web/libs/new-block-editor/tsconfig.json | 31 +++++++++++++ .../libs/new-block-editor/tsconfig.lib.json | 12 ++++++ .../libs/new-block-editor/tsconfig.spec.json | 15 +++++++ core-web/tsconfig.base.json | 2 + 9 files changed, 194 insertions(+) create mode 100644 core-web/libs/new-block-editor/.eslintrc.json create mode 100644 core-web/libs/new-block-editor/jest.config.ts create mode 100644 core-web/libs/new-block-editor/project.json create mode 100644 core-web/libs/new-block-editor/src/index.ts create mode 100644 core-web/libs/new-block-editor/src/test-setup.ts create mode 100644 core-web/libs/new-block-editor/tsconfig.json create mode 100644 core-web/libs/new-block-editor/tsconfig.lib.json create mode 100644 core-web/libs/new-block-editor/tsconfig.spec.json 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/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/index.ts b/core-web/libs/new-block-editor/src/index.ts new file mode 100644 index 000000000000..dc4b1134e6f2 --- /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 {}; 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/tsconfig.base.json b/core-web/tsconfig.base.json index d9174ec5de83..7e2fc5bb3b23 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"], From 6b9eedcbd3d7a2e80e94522ca817f7f559b2799d Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 8 Apr 2026 11:03:30 -0400 Subject: [PATCH 02/51] refactor(Block Editor): update configuration and dependencies and add the new Block Editor V1 --- .../apps/dotcms-block-editor/project.json | 39 +- .../src/app/app.component.ts | 8 +- .../dotcms-block-editor/src/app/app.config.ts | 6 + .../dotcms-block-editor/src/app/app.module.ts | 53 -- .../apps/dotcms-block-editor/src/index.html | 7 +- core-web/apps/dotcms-block-editor/src/main.ts | 9 +- .../apps/dotcms-block-editor/src/styles.css | 220 +++++- .../dotcms-block-editor/tsconfig.app.json | 6 +- core-web/libs/dotcms-scss/angular/styles.scss | 8 - core-web/libs/new-block-editor/src/index.ts | 2 +- .../new-block-editor/src/lib/app.config.ts | 29 + .../new-block-editor/src/lib/app.routes.ts | 3 + .../libs/new-block-editor/src/lib/app.spec.ts | 24 + core-web/libs/new-block-editor/src/lib/app.ts | 13 + .../blocks/floating-block-dialog.base.ts | 28 + .../blocks/image/image-dialog.component.ts | 378 +++++++++++ .../blocks/image/image-dialog.service.ts | 41 ++ .../blocks/link/link-dialog.component.ts | 181 +++++ .../editor/blocks/link/link-dialog.service.ts | 46 ++ .../blocks/table/table-dialog.component.ts | 191 ++++++ .../blocks/table/table-dialog.service.ts | 32 + .../blocks/video/video-dialog.component.ts | 294 ++++++++ .../blocks/video/video-dialog.service.ts | 27 + .../src/lib/editor/editor-character-stats.ts | 24 + .../src/lib/editor/editor-chrome-click.ts | 85 +++ .../src/lib/editor/editor-demo-content.ts | 41 ++ .../src/lib/editor/editor.component.ts | 126 ++++ .../src/lib/editor/editor.utils.ts | 93 +++ .../emoji-menu/emoji-picker.component.ts | 117 ++++ .../editor/emoji-menu/emoji-picker.service.ts | 31 + .../extensions/block-gutter.extension.ts | 112 +++ .../editor/extensions/editor-extensions.ts | 77 +++ .../extensions/slash-command.extension.ts | 86 +++ .../upload-placeholder.extension.ts | 100 +++ .../lib/editor/extensions/video.extension.ts | 25 + .../services/dot-cms-content-type.service.ts | 41 ++ .../services/dot-cms-contentlet.service.ts | 52 ++ .../editor/services/dot-cms-upload.service.ts | 93 +++ .../src/lib/editor/services/dot-cms.config.ts | 7 + .../editor/slash-menu/slash-menu-catalog.ts | 288 ++++++++ .../editor/slash-menu/slash-menu.component.ts | 132 ++++ .../editor/slash-menu/slash-menu.service.ts | 205 ++++++ .../lib/editor/slash-menu/slash-menu.types.ts | 16 + .../toolbar/editor-toolbar-state.service.ts | 61 ++ .../lib/editor/toolbar/toolbar.component.ts | 484 +++++++++++++ core-web/package.json | 55 +- core-web/yarn.lock | 638 ++++++++++-------- 47 files changed, 4255 insertions(+), 379 deletions(-) create mode 100644 core-web/apps/dotcms-block-editor/src/app/app.config.ts delete mode 100644 core-web/apps/dotcms-block-editor/src/app/app.module.ts create mode 100644 core-web/libs/new-block-editor/src/lib/app.config.ts create mode 100644 core-web/libs/new-block-editor/src/lib/app.routes.ts create mode 100644 core-web/libs/new-block-editor/src/lib/app.spec.ts create mode 100644 core-web/libs/new-block-editor/src/lib/app.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/floating-block-dialog.base.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/editor.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/editor.utils.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/slash-command.extension.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/upload-placeholder.extension.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/video.extension.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-content-type.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-contentlet.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-upload.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/dot-cms.config.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu-catalog.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.types.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/toolbar/editor-toolbar-state.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/toolbar/toolbar.component.ts 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..6511ea267ae7 --- /dev/null +++ b/core-web/apps/dotcms-block-editor/src/app/app.config.ts @@ -0,0 +1,6 @@ +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideHttpClient()] +}; 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/src/index.ts b/core-web/libs/new-block-editor/src/index.ts index dc4b1134e6f2..aac7aa758c6a 100644 --- a/core-web/libs/new-block-editor/src/index.ts +++ b/core-web/libs/new-block-editor/src/index.ts @@ -2,4 +2,4 @@ * Experimental block editor playground — add feature exports from `lib/` as you refactor. */ -export {}; +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/blocks/floating-block-dialog.base.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/floating-block-dialog.base.ts new file mode 100644 index 000000000000..04a2bad34bc2 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/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/blocks/image/image-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.component.ts new file mode 100644 index 000000000000..df0ca00fed09 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.component.ts @@ -0,0 +1,378 @@ +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 { ImageDialogService } from './image-dialog.service'; + +import { DotCmsUploadService } from '../../services/dot-cms-upload.service'; + +type Tab = 'upload' | 'url'; + +@Component({ + selector: 'dot-block-editor-image-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule], + host: { + '[attr.aria-label]': 'isEditing() ? "Edit image" : "Insert image"', + class: 'absolute z-50 w-96 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') { +
+ + +
+ +
+
+ } + } + ` +}) +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); + + 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); + + private previouslyFocused: HTMLElement | null = null; + + readonly urlControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }); + + 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.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 flex-1 items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors'; + 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`; + } + + 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/blocks/image/image-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.service.ts new file mode 100644 index 000000000000..a6cbdf3fea80 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/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/blocks/link/link-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.component.ts new file mode 100644 index 000000000000..f448de2fa286 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.component.ts @@ -0,0 +1,181 @@ +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 }) + }); + + 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 }); + } + }); + }); + + 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: '' }); + }); + 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 } = this.form.getRawValue(); + this.service.insert(href, displayText.trim() || undefined); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.service.ts new file mode 100644 index 000000000000..0cebb9e7a6de --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.service.ts @@ -0,0 +1,46 @@ +import { Injectable, signal } from '@angular/core'; + +import { FloatingBlockDialogService } from '../floating-block-dialog.base'; + +export type InsertLinkFn = (href: string, displayText?: string) => void; + +export interface LinkInitialValues { + href: string; + displayText: string; +} + +@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?: LinkInitialValues, + linkEl?: HTMLElement + ): void { + this.openFloating(clientRectFn, () => { + this.insertFn = insertFn; + this.initialValues.set(initialValues ?? null); + this.activeLinkEl = linkEl ?? null; + this.activeLinkEl?.classList.add('link-editing'); + }); + } + + insert(href: string, displayText?: string): void { + this.insertFn?.(href, displayText); + 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/blocks/table/table-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.component.ts new file mode 100644 index 000000000000..26a1ea9c4ec3 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/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: ` +
+

+ Insert Table +

+ +
+
+ + +
+
+ + +
+
+ + + +
+ + +
+
+ ` +}) +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/blocks/table/table-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.service.ts new file mode 100644 index 000000000000..a96b88bb0414 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/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/blocks/video/video-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.component.ts new file mode 100644 index 000000000000..bd7def939b14 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.component.ts @@ -0,0 +1,294 @@ +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 { VideoDialogService } from './video-dialog.service'; + +import { DotCmsUploadService } from '../../services/dot-cms-upload.service'; + +type Tab = 'upload' | 'url'; + +@Component({ + selector: 'dot-block-editor-video-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule], + host: { + 'aria-label': 'Insert video', + 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: ` + +
+ + +
+ + + @if (activeTab() === 'upload') { +
+ +
+ } + + + @if (activeTab() === 'url') { +
+
+ + +
+
+ + +
+
+ +
+
+ } + ` +}) +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); + + 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); + + private previouslyFocused: HTMLElement | null = null; + + readonly urlControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }); + + readonly titleControl = 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(''); + }); + 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 flex-1 items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors'; + 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`; + } + + 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/blocks/video/video-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.service.ts new file mode 100644 index 000000000000..c152f365ebae --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/blocks/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..9142dfcf25e9 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts @@ -0,0 +1,85 @@ +import type { Editor } from '@tiptap/core'; + +import type { ImageDialogService } from './blocks/image/image-dialog.service'; +import type { LinkDialogService } from './blocks/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 { + 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
  • +
+
    +
  1. First ordered item
  2. +
  3. Second ordered item
  4. +
  5. Third ordered item
  6. +
+ +

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

+ + + + + + + + + + + + +
FeatureStatusNotes
Slash menu✅ DoneType / to trigger
Drag & drop✅ DoneGrab the handle on the left
Tables✅ DoneResizable columns
Links✅ DoneAutolink + dialog
Images✅ DoneURL or file upload
Video✅ DoneURL 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..0b4496308c32 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts @@ -0,0 +1,126 @@ +import { TiptapEditorDirective } from 'ngx-tiptap'; + +import { ChangeDetectionStrategy, Component, OnDestroy, inject, signal } from '@angular/core'; + +import { Editor } from '@tiptap/core'; + +import { ImageDialogComponent } from './blocks/image/image-dialog.component'; +import { ImageDialogService } from './blocks/image/image-dialog.service'; +import { LinkDialogComponent } from './blocks/link/link-dialog.component'; +import { LinkDialogService } from './blocks/link/link-dialog.service'; +import { TableDialogComponent } from './blocks/table/table-dialog.component'; +import { VideoDialogComponent } from './blocks/video/video-dialog.component'; +import { syncCharacterStatsFromEditor } from './editor-character-stats'; +import { handleEditorProseMirrorClick } from './editor-chrome-click'; +import { EDITOR_DEMO_CONTENT } from './editor-demo-content'; +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, + 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: 550px; + } + ` +}) +export class EditorComponent implements OnDestroy { + protected readonly menuService = inject(SlashMenuService); + private readonly linkDialogService = inject(LinkDialogService); + private readonly imageDialogService = inject(ImageDialogService); + private readonly dotCmsUpload = inject(DotCmsUploadService); + + 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), + 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), + content: EDITOR_DEMO_CONTENT + }); + + onClick(event: MouseEvent): void { + handleEditorProseMirrorClick( + event, + this.editor, + this.imageDialogService, + this.linkDialogService + ); + } + + ngOnDestroy(): void { + this.editor.destroy(); + } +} 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..acf603416e2b --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts @@ -0,0 +1,112 @@ +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'; + +/** Last valid text cursor inside this block (end of inline content), or start of an empty textblock. */ +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); +} + +/** + * TipTap's drag-handle plugin always attaches `draggable` and drag listeners to the **single** + * root node from `render()`. We keep one wrapper for layout, but: + * - Put the grip first (toward the block) so the "+" stays in the padded gutter and is not clipped. + * - Mark the "+" as non-draggable and cancel `dragstart` so it does not start a block drag. + * - Use Floating UI `shift` so the gutter stays on-screen (default config has none). + */ +export function createBlockGutterDragHandle() { + const state: { editor: Editor | null; pos: number; nodeSize: number } = { + editor: null, + pos: -1, + nodeSize: 0 + }; + + return DragHandle.configure({ + computePositionConfig: { + ...defaultComputePositionConfig, + middleware: [shift({ padding: 8 })] + }, + onNodeChange: (payload) => { + const { editor, node } = payload; + // Runtime includes `pos`; @tiptap/extension-drag-handle types omit it. + const pos = (payload as { pos?: number }).pos; + state.editor = editor; + state.pos = pos ?? -1; + state.nodeSize = node?.nodeSize ?? 0; + }, + render() { + const root = document.createElement('div'); + root.className = 'drag-handle-wrapper'; + root.style.visibility = 'hidden'; + + const dragEl = document.createElement('div'); + dragEl.className = 'drag-handle'; + dragEl.setAttribute('aria-hidden', 'true'); + dragEl.innerHTML = ``; + + 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 = ``; + addBtn.addEventListener('dragstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + addBtn.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.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(); + }); + + root.appendChild(dragEl); + root.appendChild(addBtn); + return root; + } + }); +} 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..7762aa759c8c --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts @@ -0,0 +1,77 @@ +import type { Extensions } from '@tiptap/core'; +import CharacterCount from '@tiptap/extension-character-count'; +import Emoji, { emojis } from '@tiptap/extension-emoji'; +import Image from '@tiptap/extension-image'; +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 { 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): Extensions { + return [ + StarterKit.configure({ + dropcursor: { + color: '#6366f1', + width: 2 + } + }), + createBlockGutterDragHandle(), + CharacterCount, + TableKit, + Image, + Link.configure({ + openOnClick: false, + enableClickSelection: true, + autolink: true, + linkOnPaste: true, + HTMLAttributes: { + rel: 'noopener noreferrer', + target: '_self' + } + }), + Video, + UploadPlaceholderExtension, + Emoji.configure({ + emojis, + enableEmoticons: true, + // No suggestion — toolbar button opens the emoji-mart picker instead. + // Input rules still work: typing :shortcode: auto-converts. + 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/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..a56098bc158d --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/upload-placeholder.extension.ts @@ -0,0 +1,100 @@ +import { Editor, Node } from '@tiptap/core'; + +export const UploadPlaceholderExtension = Node.create({ + name: 'uploadPlaceholder', + 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 'image' | 'video'; + + const dom = document.createElement('div'); + dom.className = 'upload-placeholder'; + dom.setAttribute('contenteditable', 'false'); + dom.setAttribute('aria-label', `Uploading ${mediaType}…`); + dom.setAttribute('role', 'status'); + + const icon = document.createElement('span'); + icon.className = 'material-symbols-outlined upload-placeholder__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = mediaType === 'video' ? 'videocam' : 'image'; + + const label = document.createElement('span'); + label.className = 'upload-placeholder__label'; + label.textContent = `Uploading ${mediaType}…`; + + const barTrack = document.createElement('span'); + barTrack.className = 'upload-placeholder__bar'; + + dom.append(icon, label, barTrack); + + return { dom }; + }; + } +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +export function insertUploadPlaceholders( + editor: Editor, + pos: number, + placeholders: Array<{ id: string; mediaType: 'image' | 'video' }> +): void { + const content = placeholders.map(({ id, mediaType }) => ({ + type: 'uploadPlaceholder', + attrs: { id, mediaType } + })); + editor.chain().focus().insertContentAt(pos, content).run(); +} + +export function replacePlaceholder(editor: Editor, placeholderId: string, content: object): void { + let targetPos: number | null = null; + + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'uploadPlaceholder' && node.attrs['id'] === placeholderId) { + targetPos = pos; + return false; + } + return true; + }); + + if (targetPos !== null) { + editor.chain().setNodeSelection(targetPos).insertContent(content).run(); + } +} + +export function removePlaceholder(editor: Editor, placeholderId: string): void { + let targetPos: number | null = null; + + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'uploadPlaceholder' && node.attrs['id'] === placeholderId) { + targetPos = pos; + return false; + } + return true; + }); + + 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..41f9aa8cb620 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/video.extension.ts @@ -0,0 +1,25 @@ +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) + ]; + } +}); 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..227bba9c5518 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/services/dot-cms-contentlet.service.ts @@ -0,0 +1,52 @@ +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; +} + +/** POST /api/content/_search wraps results in ResponseEntityView → SearchView. */ +interface ContentSearchResponse { + entity?: { + contentTook?: number; + jsonObjectView?: { contentlets?: DotCmsContentlet[] }; + queryTook?: number; + resultsSize?: number; + }; +} + +@Injectable({ providedIn: 'root' }) +export class DotCmsContentletService { + private readonly http = inject(HttpClient); + + 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..37ce5350901b --- /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.eyJzdWIiOiJhcGkwNjM5M2Q2OS02MjI2LTQwNjYtYTVkMy05NjQyMTAwYzNlMjMiLCJ4bW9kIjoxNzc1NTA3MTgxMDAwLCJuYmYiOjE3NzU1MDcxODEsImlzcyI6ImRvdGNtcy1wcm9kdWN0aW9uIiwibGFiZWwiOiJVVkUiLCJleHAiOjE4NzAxNDI0MDAsImlhdCI6MTc3NTUwNzE4MSwianRpIjoiZWIxNDdhODMtZmUzOS00ZmU5LTljOTQtNjdiMTc2MTE5NzhiIn0.wuFlBodGTwkbaNQQbugWkEewUxpcDLY_nE2kJb9Mevw'; 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..d13056c8ea17 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu-catalog.ts @@ -0,0 +1,288 @@ +import { take } from 'rxjs/operators'; + +import { SuggestionPluginKey } from '@tiptap/suggestion'; + +import type { BlockItem } from './slash-menu.types'; +import type { ImageDialogService } from '../blocks/image/image-dialog.service'; +import type { LinkDialogService } from '../blocks/link/link-dialog.service'; +import type { TableDialogService } from '../blocks/table/table-dialog.service'; +import type { VideoDialogService } from '../blocks/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; +} + +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'], + 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(); + } + + // The slash range is now just the "/" character itself. + const slashRange = range ? { from: range.from, to: range.from + 1 } : null; + + contentTypeService + .fetchAll() + .pipe(take(1)) + .toPromise() + .then((types: DotCmsContentType[] | undefined) => { + if (!types) return; + // Content type items are plain display items — all drill-down logic lives + // in the commandFn below, which has closure access to editor, slashRange, + // and services. This avoids passing range through item.onSelect and keeps + // the query-text deletion (which resets the Tiptap query to "") reliable. + const items: BlockItem[] = types.map((ct) => ({ + label: ct.name, + description: ct.description || ct.variable, + icon: ct.icon || '⬡', + keywords: [ct.variable, ct.baseType.toLowerCase()] + })); + + menuService.setItems(items, (selectedItem) => { + 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) => { + if (!contentlets) return; + const contentletItems: BlockItem[] = contentlets.map((cl) => ({ + label: cl.title || cl.identifier, + description: cl.contentType, + icon: '◈', + keywords: [cl.contentType, cl.identifier], + onSelect: () => { + /* TODO: insert contentlet block */ + } + })); + + const finalItems: BlockItem[] = + contentletItems.length === 0 + ? [ + { + label: 'No contentlets found', + description: `No ${selectedItem.label} contentlets available`, + icon: '○', + keywords: [] + } + ] + : contentletItems; + + menuService.setItems(finalItems, (contentletItem) => { + contentletItem.onSelect?.(editor); + menuService.close(); + if (slashRange) + editor.chain().focus().deleteRange(slashRange).run(); + }); + }) + .catch(() => menuService.close()); + }); + }) + .catch(() => menuService.close()); + } + }; +} + +export const ALL_ITEMS: BlockItem[] = [ + { + label: 'Text', + description: 'Plain text paragraph', + icon: 'P', + keywords: ['paragraph', 'text'], + apply: (c) => c.setParagraph() + }, + { + label: 'Heading 1', + description: 'Top-level title or page heading', + icon: 'H1', + keywords: ['h1', 'heading', 'title'], + apply: (c) => c.setHeading({ level: 1 }) + }, + { + label: 'Heading 2', + description: 'Section heading', + icon: 'H2', + keywords: ['h2', 'heading', 'subtitle'], + apply: (c) => c.setHeading({ level: 2 }) + }, + { + label: 'Heading 3', + description: 'Subsection heading', + icon: 'H3', + keywords: ['h3', 'heading'], + apply: (c) => c.setHeading({ level: 3 }) + }, + { + label: 'Bullet List', + description: 'Unordered list of items', + icon: '•', + keywords: ['ul', 'list', 'bullets'], + apply: (c) => c.toggleBulletList() + }, + { + label: 'Ordered List', + description: 'Numbered list of steps or items', + icon: '1.', + keywords: ['ol', 'numbered', 'list'], + apply: (c) => c.toggleOrderedList() + }, + { + label: 'Blockquote', + description: 'Highlighted quote or callout', + icon: '"', + keywords: ['quote', 'callout', 'cite'], + apply: (c) => c.toggleBlockquote() + }, + { + label: 'Code Block', + description: 'Code snippet with syntax highlighting', + icon: '', + keywords: ['code', 'pre', 'snippet'], + apply: (c) => c.setCodeBlock() + } +]; + +export interface SlashDialogServices { + table: TableDialogService; + image: ImageDialogService; + video: VideoDialogService; + link: LinkDialogService; +} + +/** Slash entries that open a floating dialog before mutating the document. */ +export function createSlashDialogBlockItems(services: SlashDialogServices): BlockItem[] { + const { table, image, video, link } = services; + + return [ + { + label: 'Table', + description: 'Organize data in rows and columns', + icon: '⊞', + keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], + 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'], + 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'], + 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 + ); + } + }, + { + label: 'Link', + description: 'Add a clickable hyperlink', + icon: '↗', + keywords: ['link', 'url', 'href', 'hyperlink', 'anchor'], + 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); + link.open( + (href, displayText) => { + editor + .chain() + .focus() + .insertContent({ + type: 'text', + text: displayText ?? href, + marks: [{ type: 'link', attrs: { href } }] + }) + .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..7b38282aea5e --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.component.ts @@ -0,0 +1,132 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + 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: ` + + ` +}) +export class SlashMenuComponent { + protected readonly service = inject(SlashMenuService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + + 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); + + constructor() { + 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); + }); + }); + }); + }); + } + + 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..2e70a6b4aff2 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.service.ts @@ -0,0 +1,205 @@ +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 '../blocks/image/image-dialog.service'; +import { LinkDialogService } from '../blocks/link/link-dialog.service'; +import { TableDialogService } from '../blocks/table/table-dialog.service'; +import { VideoDialogService } from '../blocks/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 linkDialogService = inject(LinkDialogService); + private readonly contentTypeService = inject(DotCmsContentTypeService); + private readonly contentletService = inject(DotCmsContentletService); + + private readonly dialogBlockItems = createSlashDialogBlockItems({ + table: this.tableDialogService, + image: this.imageDialogService, + video: this.videoDialogService, + link: this.linkDialogService + }); + + private readonly contentTypeItem = createContentTypeItem( + this, + this.contentTypeService, + this.contentletService + ); + + 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 q = query.toLowerCase().trim(); + if (!q) return all; + return all.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..3327c2fb085f --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.types.ts @@ -0,0 +1,16 @@ +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; + 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..bae036fe05eb --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/toolbar/editor-toolbar-state.service.ts @@ -0,0 +1,61 @@ +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); + + 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.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..789f1954e43b --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/toolbar/toolbar.component.ts @@ -0,0 +1,484 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input +} from '@angular/core'; + +import { Editor } from '@tiptap/core'; + +import { EditorToolbarStateService } from './editor-toolbar-state.service'; + +import { ImageDialogService } from '../blocks/image/image-dialog.service'; +import { LinkDialogService } from '../blocks/link/link-dialog.service'; +import { TableDialogService } from '../blocks/table/table-dialog.service'; +import { VideoDialogService } from '../blocks/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: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` +}) +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(); + + 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 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}`; + }); + + // ── 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(); + } + + // ── Dialog openers ──────────────────────────────────────────────────────── + + protected openLinkDialog(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (this.linkDialogService.isOpen()) { + this.linkDialogService.close(); + return; + } + 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) => { + editor + .chain() + .focus() + .setTextSelection(anchorPos) + .extendMarkRange('link') + .insertContent({ + type: 'text', + text: newDisplayText ?? newHref, + marks: [{ type: 'link', attrs: { href: newHref } }] + }) + .run(); + }, + () => linkEl.getBoundingClientRect(), + { href, displayText }, + linkEl + ); + } else { + // Insert mode — anchor to the toolbar button + const selectedText = empty ? '' : editor.state.doc.textBetween(from, to); + this.linkDialogService.open( + (href, displayText) => { + editor + .chain() + .focus() + .insertContent({ + type: 'text', + text: displayText ?? href, + marks: [{ type: 'link', attrs: { href } }] + }) + .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; + } + 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; + } + 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; + } + 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 { + const btn = event.currentTarget as HTMLElement; + this.emojiPickerService.open( + (emoji) => this.editor().chain().focus().insertContent(emoji).run(), + () => btn.getBoundingClientRect() + ); + } + + // ── 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/package.json b/core-web/package.json index 2ae235433368..6f4692de8909 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/yarn.lock b/core-web/yarn.lock index 56696c3272c3..446202a7b1ce 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" @@ -20343,7 +20437,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== @@ -20460,7 +20563,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== @@ -20849,13 +20959,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" @@ -22131,7 +22234,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== @@ -22149,6 +22252,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" From 7e193ef501cbaf8591ea52a5b1dbfbdb29fcec72 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 8 Apr 2026 12:12:41 -0400 Subject: [PATCH 03/51] feat(new-block-editor): add interaction preferences and upload placeholder enhancements --- core-web/libs/new-block-editor/CLAUDE.md | 39 +++ .../extensions/block-gutter.extension.ts | 305 +++++++++++++----- .../upload-placeholder.extension.ts | 171 +++++++--- 3 files changed, 390 insertions(+), 125 deletions(-) create mode 100644 core-web/libs/new-block-editor/CLAUDE.md 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/src/lib/editor/extensions/block-gutter.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/block-gutter.extension.ts index acf603416e2b..e0a9a9787fed 100644 --- 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 @@ -4,7 +4,37 @@ import type { Editor } from '@tiptap/core'; import { DragHandle, defaultComputePositionConfig } from '@tiptap/extension-drag-handle'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; -/** Last valid text cursor inside this block (end of inline content), or start of an empty textblock. */ +/** + * 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; @@ -18,95 +48,216 @@ function endPosInsideBlock(doc: ProseMirrorNode, blockPos: number): number | nul 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); + }; +} + /** - * TipTap's drag-handle plugin always attaches `draggable` and drag listeners to the **single** - * root node from `render()`. We keep one wrapper for layout, but: - * - Put the grip first (toward the block) so the "+" stays in the padded gutter and is not clipped. - * - Mark the "+" as non-draggable and cancel `dragstart` so it does not start a block drag. - * - Use Floating UI `shift` so the gutter stays on-screen (default config has none). + * 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: { editor: Editor | null; pos: number; nodeSize: number } = { + 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) => { - const { editor, node } = payload; - // Runtime includes `pos`; @tiptap/extension-drag-handle types omit it. - const pos = (payload as { pos?: number }).pos; - state.editor = editor; - state.pos = pos ?? -1; - state.nodeSize = node?.nodeSize ?? 0; + onNodeChange: (payload) => + handleNodeChange( + payload as DragHandleNodeChangePayload, + state, + fixDragImageOffset, + listenerRegistered + ), + onElementDragStart: () => { + isDragHandleDrag = true; + document.documentElement.style.setProperty('cursor', 'grabbing', 'important'); }, - render() { - const root = document.createElement('div'); - root.className = 'drag-handle-wrapper'; - root.style.visibility = 'hidden'; - - const dragEl = document.createElement('div'); - dragEl.className = 'drag-handle'; - dragEl.setAttribute('aria-hidden', 'true'); - dragEl.innerHTML = ``; - - 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 = ``; - addBtn.addEventListener('dragstart', (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - addBtn.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.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(); - }); - - root.appendChild(dragEl); - root.appendChild(addBtn); - return root; - } + onElementDragEnd: () => { + isDragHandleDrag = false; + document.documentElement.style.removeProperty('cursor'); + }, + render: () => createGutterRoot(state) }); } 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 index a56098bc158d..0177cf389393 100644 --- 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 @@ -1,7 +1,103 @@ 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: 'uploadPlaceholder', + name: PLACEHOLDER_NODE_NAME, group: 'block', atom: true, selectable: false, @@ -26,74 +122,53 @@ export const UploadPlaceholderExtension = Node.create({ addNodeView() { return ({ node }) => { - const mediaType = node.attrs['mediaType'] as 'image' | 'video'; - - const dom = document.createElement('div'); - dom.className = 'upload-placeholder'; - dom.setAttribute('contenteditable', 'false'); - dom.setAttribute('aria-label', `Uploading ${mediaType}…`); - dom.setAttribute('role', 'status'); - - const icon = document.createElement('span'); - icon.className = 'material-symbols-outlined upload-placeholder__icon'; - icon.setAttribute('aria-hidden', 'true'); - icon.textContent = mediaType === 'video' ? 'videocam' : 'image'; - - const label = document.createElement('span'); - label.className = 'upload-placeholder__label'; - label.textContent = `Uploading ${mediaType}…`; - - const barTrack = document.createElement('span'); - barTrack.className = 'upload-placeholder__bar'; - - dom.append(icon, label, barTrack); - - return { dom }; + const mediaType = node.attrs['mediaType'] as UploadPlaceholderMediaType; + return { dom: createUploadPlaceholderDom(mediaType) }; }; } }); -// ── Helpers ────────────────────────────────────────────────────────────────── - +/** + * 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: Array<{ id: string; mediaType: 'image' | 'video' }> + placeholders: UploadPlaceholderItem[] ): void { const content = placeholders.map(({ id, mediaType }) => ({ - type: 'uploadPlaceholder', + 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 { - let targetPos: number | null = null; - - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'uploadPlaceholder' && node.attrs['id'] === placeholderId) { - targetPos = pos; - return false; - } - return true; - }); - + 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 { - let targetPos: number | null = null; - - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'uploadPlaceholder' && node.attrs['id'] === placeholderId) { - targetPos = pos; - return false; - } - return true; - }); - + const targetPos = findUploadPlaceholderPosition(editor.state.doc, placeholderId); if (targetPos !== null) { editor.chain().setNodeSelection(targetPos).deleteSelection().run(); } From 9d014aa74ffced234ae4cc1afbcc5eedf9e4488d Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 8 Apr 2026 12:26:29 -0400 Subject: [PATCH 04/51] fix(toolbar): change emoji picker trigger from click to mousedown event --- .../src/lib/editor/toolbar/toolbar.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 789f1954e43b..7ad81cdec1da 100644 --- 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 @@ -211,7 +211,7 @@ import { EmojiPickerService } from '../emoji-menu/emoji-picker.service'; type="button" aria-label="Insert emoji" [class]="btnClass(false)" - (click)="openEmojiPicker($event)"> + (mousedown)="openEmojiPicker($event)"> ` From 37a8a7e21f24cbe443cc17c81957413c0c75aa58 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 8 Apr 2026 12:34:45 -0400 Subject: [PATCH 05/51] fix(toolbar): prevent default behavior and close emoji picker if already open --- .../src/lib/editor/toolbar/toolbar.component.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 7ad81cdec1da..b6533ffac743 100644 --- 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 @@ -456,6 +456,12 @@ export class ToolbarComponent implements OnDestroy { } protected openEmojiPicker(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (this.emojiPickerService.isOpen()) { + this.emojiPickerService.close(); + return; + } const btn = event.currentTarget as HTMLElement; this.emojiPickerService.open( (emoji) => this.editor().chain().focus().insertContent(emoji).run(), From 3317167bfffa7e244b6ca799e3d25434019cb0cf Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 9 Apr 2026 16:41:58 -0400 Subject: [PATCH 06/51] feat: add contentlet node --- .../editor/extensions/contentlet.extension.ts | 109 ++++++++++++++++++ .../editor/extensions/editor-extensions.ts | 2 + .../src/lib/editor/services/dot-cms.config.ts | 2 +- .../editor/slash-menu/slash-menu-catalog.ts | 48 ++++++-- 4 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/contentlet.extension.ts 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..fbe54c6206c0 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/contentlet.extension.ts @@ -0,0 +1,109 @@ +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"]' }]; + }, + + 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 index 7762aa759c8c..c1d750685fde 100644 --- 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 @@ -8,6 +8,7 @@ import { TableKit } from '@tiptap/extension-table'; import StarterKit from '@tiptap/starter-kit'; import { createBlockGutterDragHandle } from './block-gutter.extension'; +import { DotContentlet } from './contentlet.extension'; import { createSlashCommandExtension } from './slash-command.extension'; import { UploadPlaceholderExtension } from './upload-placeholder.extension'; import { Video } from './video.extension'; @@ -37,6 +38,7 @@ export function createEditorExtensions(menuService: SlashMenuService): Extension } }), Video, + DotContentlet, UploadPlaceholderExtension, Emoji.configure({ emojis, 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 index 37ce5350901b..96d2c248cb50 100644 --- 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 @@ -4,4 +4,4 @@ */ export const DOT_CMS_BASE_URL = 'http://localhost:8080'; export const DOT_CMS_AUTH_TOKEN = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhcGkwNjM5M2Q2OS02MjI2LTQwNjYtYTVkMy05NjQyMTAwYzNlMjMiLCJ4bW9kIjoxNzc1NTA3MTgxMDAwLCJuYmYiOjE3NzU1MDcxODEsImlzcyI6ImRvdGNtcy1wcm9kdWN0aW9uIiwibGFiZWwiOiJVVkUiLCJleHAiOjE4NzAxNDI0MDAsImlhdCI6MTc3NTUwNzE4MSwianRpIjoiZWIxNDdhODMtZmUzOS00ZmU5LTljOTQtNjdiMTc2MTE5NzhiIn0.wuFlBodGTwkbaNQQbugWkEewUxpcDLY_nE2kJb9Mevw'; + '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 index d13056c8ea17..b0f8c4908964 100644 --- 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 @@ -2,6 +2,8 @@ import { take } from 'rxjs/operators'; 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 '../blocks/image/image-dialog.service'; import type { LinkDialogService } from '../blocks/link/link-dialog.service'; @@ -49,19 +51,14 @@ export function createContentTypeItem( .run(); } - // The slash range is now just the "/" character itself. - const slashRange = range ? { from: range.from, to: range.from + 1 } : null; - contentTypeService .fetchAll() .pipe(take(1)) .toPromise() .then((types: DotCmsContentType[] | undefined) => { if (!types) return; - // Content type items are plain display items — all drill-down logic lives - // in the commandFn below, which has closure access to editor, slashRange, - // and services. This avoids passing range through item.onSelect and keeps - // the query-text deletion (which resets the Tiptap query to "") reliable. + // Content type items are plain display items — drill-down logic lives in the + // commandFn below (closure over editor and services). const items: BlockItem[] = types.map((ct) => ({ label: ct.name, description: ct.description || ct.variable, @@ -97,8 +94,24 @@ export function createContentTypeItem( description: cl.contentType, icon: '◈', keywords: [cl.contentType, cl.identifier], - onSelect: () => { - /* TODO: insert contentlet block */ + onSelect: (editor) => { + const match = SuggestionPluginKey.getState(editor.state); + const chain = editor.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(); } })); @@ -115,10 +128,21 @@ export function createContentTypeItem( : contentletItems; menuService.setItems(finalItems, (contentletItem) => { - contentletItem.onSelect?.(editor); + if (contentletItem.onSelect) { + contentletItem.onSelect(editor); + } else { + const slashMatch = SuggestionPluginKey.getState( + editor.state + ); + if (slashMatch?.active) { + editor + .chain() + .focus() + .deleteRange(slashMatch.range) + .run(); + } + } menuService.close(); - if (slashRange) - editor.chain().focus().deleteRange(slashRange).run(); }); }) .catch(() => menuService.close()); From a2553f103eabf175211269e2a473b7e51131b2a1 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 9 Apr 2026 16:44:11 -0400 Subject: [PATCH 07/51] refactor(new-block-editor): reorganize component structure and update dialog services --- .../floating-block-dialog.base.ts | 0 .../image/image-dialog.component.ts | 0 .../image/image-dialog.service.ts | 0 .../link/link-dialog.component.ts | 0 .../link/link-dialog.service.ts | 0 .../table/table-dialog.component.ts | 0 .../table/table-dialog.service.ts | 0 .../video/video-dialog.component.ts | 0 .../video/video-dialog.service.ts | 0 .../src/lib/editor/editor-chrome-click.ts | 4 ++-- .../src/lib/editor/editor.component.ts | 12 ++++++------ .../src/lib/editor/slash-menu/slash-menu-catalog.ts | 8 ++++---- .../src/lib/editor/slash-menu/slash-menu.service.ts | 8 ++++---- .../src/lib/editor/toolbar/toolbar.component.ts | 8 ++++---- 14 files changed, 20 insertions(+), 20 deletions(-) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/floating-block-dialog.base.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/image/image-dialog.component.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/image/image-dialog.service.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/link/link-dialog.component.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/link/link-dialog.service.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/table/table-dialog.component.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/table/table-dialog.service.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/video/video-dialog.component.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/{blocks => components}/video/video-dialog.service.ts (100%) diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/floating-block-dialog.base.ts b/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/floating-block-dialog.base.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.component.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/image/image-dialog.service.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.component.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/link/link-dialog.service.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.component.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/table/table-dialog.service.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.component.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/blocks/video/video-dialog.service.ts rename to core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts 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 index 9142dfcf25e9..799d7d6ca8bd 100644 --- 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 @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/core'; -import type { ImageDialogService } from './blocks/image/image-dialog.service'; -import type { LinkDialogService } from './blocks/link/link-dialog.service'; +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). 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 index 0b4496308c32..5d6cedcd1896 100644 --- 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 @@ -4,12 +4,12 @@ import { ChangeDetectionStrategy, Component, OnDestroy, inject, signal } from '@ import { Editor } from '@tiptap/core'; -import { ImageDialogComponent } from './blocks/image/image-dialog.component'; -import { ImageDialogService } from './blocks/image/image-dialog.service'; -import { LinkDialogComponent } from './blocks/link/link-dialog.component'; -import { LinkDialogService } from './blocks/link/link-dialog.service'; -import { TableDialogComponent } from './blocks/table/table-dialog.component'; -import { VideoDialogComponent } from './blocks/video/video-dialog.component'; +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 { VideoDialogComponent } from './components/video/video-dialog.component'; import { syncCharacterStatsFromEditor } from './editor-character-stats'; import { handleEditorProseMirrorClick } from './editor-chrome-click'; import { EDITOR_DEMO_CONTENT } from './editor-demo-content'; 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 index b0f8c4908964..5519b03f68f6 100644 --- 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 @@ -5,10 +5,10 @@ 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 '../blocks/image/image-dialog.service'; -import type { LinkDialogService } from '../blocks/link/link-dialog.service'; -import type { TableDialogService } from '../blocks/table/table-dialog.service'; -import type { VideoDialogService } from '../blocks/video/video-dialog.service'; +import type { ImageDialogService } from '../components/image/image-dialog.service'; +import type { LinkDialogService } from '../components/link/link-dialog.service'; +import type { TableDialogService } from '../components/table/table-dialog.service'; +import type { VideoDialogService } from '../components/video/video-dialog.service'; import type { DotCmsContentType, DotCmsContentTypeService 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 index 2e70a6b4aff2..7120f87035d6 100644 --- 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 @@ -8,10 +8,10 @@ import { createSlashDialogBlockItems } from './slash-menu-catalog'; -import { ImageDialogService } from '../blocks/image/image-dialog.service'; -import { LinkDialogService } from '../blocks/link/link-dialog.service'; -import { TableDialogService } from '../blocks/table/table-dialog.service'; -import { VideoDialogService } from '../blocks/video/video-dialog.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 { DotCmsContentTypeService } from '../services/dot-cms-content-type.service'; import { DotCmsContentletService } from '../services/dot-cms-contentlet.service'; 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 index b6533ffac743..444f23d7dd7b 100644 --- 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 @@ -12,10 +12,10 @@ import { Editor } from '@tiptap/core'; import { EditorToolbarStateService } from './editor-toolbar-state.service'; -import { ImageDialogService } from '../blocks/image/image-dialog.service'; -import { LinkDialogService } from '../blocks/link/link-dialog.service'; -import { TableDialogService } from '../blocks/table/table-dialog.service'; -import { VideoDialogService } from '../blocks/video/video-dialog.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({ From c01e884c2303ab9306c21a7f5f40f8e58ae35c4d Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 9 Apr 2026 16:55:29 -0400 Subject: [PATCH 08/51] feat(new-block-editor): enhance slash menu functionality with improved error handling and empty state management --- .../src/lib/editor/services/dot-cms.config.ts | 3 +- .../editor/slash-menu/slash-menu-catalog.ts | 151 ++++++++++++------ .../editor/slash-menu/slash-menu.component.ts | 12 +- .../lib/editor/slash-menu/slash-menu.types.ts | 7 +- 4 files changed, 118 insertions(+), 55 deletions(-) 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 index 96d2c248cb50..32249f671c36 100644 --- 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 @@ -3,5 +3,4 @@ * 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'; +export const DOT_CMS_AUTH_TOKEN = ''; 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 index 5519b03f68f6..d1b8554764c7 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -25,6 +26,13 @@ interface SlashMenuSubMenuHost { 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, @@ -56,17 +64,34 @@ export function createContentTypeItem( .pipe(take(1)) .toPromise() .then((types: DotCmsContentType[] | undefined) => { - if (!types) return; + const resolvedTypes = types ?? []; // Content type items are plain display items — drill-down logic lives in the // commandFn below (closure over editor and services). - const items: BlockItem[] = types.map((ct) => ({ - label: ct.name, - description: ct.description || ct.variable, - icon: ct.icon || '⬡', - keywords: [ct.variable, ct.baseType.toLowerCase()] - })); + 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.setItems(items, (selectedItem) => { menuService.openSubmenu(); const slashMatch = SuggestionPluginKey.getState(editor.state); @@ -88,32 +113,34 @@ export function createContentTypeItem( .pipe(take(1)) .toPromise() .then((contentlets: DotCmsContentlet[] | undefined) => { - if (!contentlets) return; - const contentletItems: BlockItem[] = contentlets.map((cl) => ({ - label: cl.title || cl.identifier, - description: cl.contentType, - icon: '◈', - keywords: [cl.contentType, cl.identifier], - onSelect: (editor) => { - const match = SuggestionPluginKey.getState(editor.state); - const chain = editor.chain().focus(); - if (match?.active) { - chain.deleteRange(match.range); + 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(); } - 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 @@ -121,8 +148,9 @@ export function createContentTypeItem( { label: 'No contentlets found', description: `No ${selectedItem.label} contentlets available`, - icon: '○', - keywords: [] + icon: '', + keywords: ['no', 'empty', 'contentlets'], + isEmptyState: true } ] : contentletItems; @@ -131,24 +159,53 @@ export function createContentTypeItem( if (contentletItem.onSelect) { contentletItem.onSelect(editor); } else { - const slashMatch = SuggestionPluginKey.getState( - editor.state - ); - if (slashMatch?.active) { - editor - .chain() - .focus() - .deleteRange(slashMatch.range) - .run(); - } + clearActiveSuggestionRange(editor); } menuService.close(); }); }) - .catch(() => 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.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(); + } + ); + }); } }; } 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 index 7b38282aea5e..baaf92da9353 100644 --- 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 @@ -40,11 +40,13 @@ import { SlashMenuService } from './slash-menu.service'; [class]="itemClass(i)" (mousedown)="$event.preventDefault(); service.select(item)" (mousemove)="onMouseMove(i)"> - + @if (item.icon) { + + } {{ item.label }} 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 index 3327c2fb085f..1cea539a1e7d 100644 --- 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 @@ -3,7 +3,7 @@ import type { ChainedCommands, Editor } from '@tiptap/core'; export interface BlockItem { label: string; description: string; - icon: string; + icon?: string; keywords: string[]; /** * When true, the slash trigger text is NOT deleted from the editor on selection. @@ -11,6 +11,11 @@ export interface BlockItem { * 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; apply?: (chain: ChainedCommands) => ChainedCommands; onSelect?: (editor: Editor, range?: { from: number; to: number }) => void; } From 77fd1d1aba17e8f7e5c4cb25929925e0c6d66c39 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 9 Apr 2026 17:13:58 -0400 Subject: [PATCH 09/51] feat(new-block-editor): add dotCMS image and video search functionality to dialogs --- .../image/image-dialog.component.ts | 172 +++++++++++++++++- .../video/video-dialog.component.ts | 168 ++++++++++++++++- .../services/dot-cms-contentlet.service.ts | 98 ++++++++++ .../src/lib/editor/services/dot-cms.config.ts | 3 +- .../editor/slash-menu/slash-menu.component.ts | 8 +- 5 files changed, 437 insertions(+), 12 deletions(-) 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 index df0ca00fed09..a3ad3bff321f 100644 --- 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 @@ -15,11 +15,18 @@ import { } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +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'; +type Tab = 'upload' | 'url' | 'dotcms'; @Component({ selector: 'dot-block-editor-image-dialog', @@ -27,7 +34,7 @@ type Tab = 'upload' | 'url'; imports: [ReactiveFormsModule], host: { '[attr.aria-label]': 'isEditing() ? "Edit image" : "Insert image"', - class: 'absolute z-50 w-96 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg', + 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()', @@ -152,13 +159,35 @@ type Tab = 'upload' | 'url'; Image URL + @if (activeTab() === 'upload') {
+ } } ` }) @@ -243,6 +359,7 @@ export class ImageDialogComponent { 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); @@ -250,6 +367,9 @@ export class ImageDialogComponent { 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); private previouslyFocused: HTMLElement | null = null; @@ -258,6 +378,8 @@ export class ImageDialogComponent { 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 }), @@ -311,6 +433,10 @@ export class ImageDialogComponent { 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.editForm.reset({ src: '', title: '', alt: '' }); }); return; @@ -344,12 +470,50 @@ export class ImageDialogComponent { tabClass(tab: Tab): string { const base = - 'flex flex-1 items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors'; + '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'); + this.loadDotcmsImages(); + } + + loadDotcmsImages(): void { + this.dotcmsLoading.set(true); + this.dotcmsError.set(null); + this.dotCmsContentlet + .searchImages({ text: this.dotcmsSearchControl.getRawValue() }) + .pipe(take(1)) + .subscribe({ + next: (list) => { + this.zone.run(() => { + this.dotcmsImages.set(list); + this.dotcmsLoading.set(false); + }); + }, + error: () => { + this.zone.run(() => { + this.dotcmsImages.set([]); + 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; 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 index bd7def939b14..22d27991d3f0 100644 --- 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 @@ -14,11 +14,18 @@ import { } from '@angular/core'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +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'; +type Tab = 'upload' | 'url' | 'dotcms'; @Component({ selector: 'dot-block-editor-video-dialog', @@ -26,7 +33,7 @@ type Tab = 'upload' | 'url'; imports: [ReactiveFormsModule], host: { 'aria-label': 'Insert video', - class: 'absolute z-50 w-80 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg', + 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()', @@ -77,6 +84,28 @@ type Tab = 'upload' | 'url'; Video URL + @@ -84,7 +113,7 @@ type Tab = 'upload' | 'url';
+ } ` }) export class VideoDialogComponent { @@ -181,12 +292,16 @@ export class VideoDialogComponent { 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); private previouslyFocused: HTMLElement | null = null; @@ -197,6 +312,8 @@ export class VideoDialogComponent { readonly titleControl = new FormControl('', { nonNullable: true }); + readonly dotcmsSearchControl = new FormControl('', { nonNullable: true }); + constructor() { effect((onCleanup) => { if (!this.service.isOpen()) return; @@ -232,6 +349,10 @@ export class VideoDialogComponent { this.activeTab.set('url'); this.urlControl.reset(''); this.titleControl.reset(''); + this.dotcmsSearchControl.reset(''); + this.dotcmsVideos.set([]); + this.dotcmsError.set(null); + this.dotcmsLoading.set(false); }); return; } @@ -264,12 +385,51 @@ export class VideoDialogComponent { tabClass(tab: Tab): string { const base = - 'flex flex-1 items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors'; + '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'); + this.loadDotcmsVideos(); + } + + loadDotcmsVideos(): void { + this.dotcmsLoading.set(true); + this.dotcmsError.set(null); + this.dotCmsContentlet + .searchVideos({ text: this.dotcmsSearchControl.getRawValue() }) + .pipe(take(1)) + .subscribe({ + next: (list) => { + this.zone.run(() => { + this.dotcmsVideos.set(list); + this.dotcmsLoading.set(false); + }); + }, + error: () => { + this.zone.run(() => { + this.dotcmsVideos.set([]); + 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; 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 index 227bba9c5518..e9cadf1eff4b 100644 --- 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 @@ -26,10 +26,108 @@ interface ContentSearchResponse { }; } +/** 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) => res.entity?.jsonObjectView?.contentlets ?? [])); + } + + 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}`, 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 index 32249f671c36..96d2c248cb50 100644 --- 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 @@ -3,4 +3,5 @@ * 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 = ''; +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.component.ts b/core-web/libs/new-block-editor/src/lib/editor/slash-menu/slash-menu.component.ts index baaf92da9353..fefc29fc58eb 100644 --- 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 @@ -1,4 +1,4 @@ -import { computePosition, flip, shift } from '@floating-ui/dom'; +import { computePosition, flip, offset, shift } from '@floating-ui/dom'; import { ChangeDetectionStrategy, @@ -102,10 +102,12 @@ export class SlashMenuComponent { 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: 'absolute', - middleware: [flip(), shift({ padding: 8 })] + strategy: 'fixed', + middleware: [offset(4), flip(), shift({ padding: 8 })] }).then(({ x, y }) => { this.zone.run(() => { untracked(() => { From 0093e510304a7da088463f4c72664779a7282dc0 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Fri, 10 Apr 2026 09:17:26 -0400 Subject: [PATCH 10/51] feat(new-block-editor): integrate PrimeNG DataView for image and video dialogs, enhancing search and display functionality --- .../dotcms-block-editor/src/app/app.config.ts | 27 ++- .../image/image-dialog.component.ts | 155 ++++++++++-------- .../video/video-dialog.component.ts | 148 ++++++++++------- .../services/dot-cms-contentlet.service.ts | 30 +++- 4 files changed, 232 insertions(+), 128 deletions(-) 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 index 6511ea267ae7..b05ceb86b8b3 100644 --- a/core-web/apps/dotcms-block-editor/src/app/app.config.ts +++ b/core-web/apps/dotcms-block-editor/src/app/app.config.ts @@ -1,6 +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()] + 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/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 index a3ad3bff321f..58a39921fcfe 100644 --- 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 @@ -15,6 +15,8 @@ import { } 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'; @@ -31,7 +33,7 @@ type Tab = 'upload' | 'url' | 'dotcms'; @Component({ selector: 'dot-block-editor-image-dialog', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule], + 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', @@ -277,76 +279,75 @@ type Tab = 'upload' | 'url' | 'dotcms'; data-testid="dotcms-image-search-input" [formControl]="dotcmsSearchControl" placeholder="Filter by name…" - (keydown.enter)="$event.preventDefault(); loadDotcmsImages()" + (keydown.enter)="$event.preventDefault(); runDotcmsSearch()" class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" /> - @if (dotcmsLoading()) { -
- - Loading images… -
- } @else if (dotcmsError()) { + @if (dotcmsError()) { - } @else if (dotcmsImages().length === 0) { -

No images found.

} @else { -
    - @for (img of dotcmsImages(); track img.inode) { -
  • - -
  • - } -
+
+ + +
+ @for (img of items; track img.inode) { + + } +
+
+
+
} } @@ -370,6 +371,12 @@ export class ImageDialogComponent { 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; @@ -437,6 +444,9 @@ export class ImageDialogComponent { 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; @@ -482,25 +492,42 @@ export class ImageDialogComponent { onSelectDotcmsTab(): void { this.activeTab.set('dotcms'); - this.loadDotcmsImages(); } - loadDotcmsImages(): void { + 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() }) + .searchImages({ + text: this.dotcmsSearchControl.getRawValue(), + offset: first, + limit: rows + }) .pipe(take(1)) .subscribe({ - next: (list) => { + next: ({ contentlets, totalRecords }) => { this.zone.run(() => { - this.dotcmsImages.set(list); + 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); }); 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 index 22d27991d3f0..48327325c672 100644 --- 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 @@ -14,6 +14,8 @@ import { } 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'; @@ -30,7 +32,7 @@ type Tab = 'upload' | 'url' | 'dotcms'; @Component({ selector: 'dot-block-editor-video-dialog', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule], + 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', @@ -216,71 +218,75 @@ type Tab = 'upload' | 'url' | 'dotcms'; data-testid="dotcms-video-search-input" [formControl]="dotcmsSearchControl" placeholder="Filter by name…" - (keydown.enter)="$event.preventDefault(); loadDotcmsVideos()" + (keydown.enter)="$event.preventDefault(); runDotcmsSearch()" class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" /> - @if (dotcmsLoading()) { -
- - Loading videos… -
- } @else if (dotcmsError()) { + @if (dotcmsError()) { - } @else if (dotcmsVideos().length === 0) { -

No videos found.

} @else { -
    - @for (vid of dotcmsVideos(); track vid.inode) { -
  • - -
  • - } -
+
+ + +
+ @for (vid of items; track vid.inode) { + + } +
+
+
+
} } @@ -302,6 +308,11 @@ export class VideoDialogComponent { 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; @@ -353,6 +364,9 @@ export class VideoDialogComponent { 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; } @@ -398,25 +412,41 @@ export class VideoDialogComponent { onSelectDotcmsTab(): void { this.activeTab.set('dotcms'); - this.loadDotcmsVideos(); } - loadDotcmsVideos(): void { + 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() }) + .searchVideos({ + text: this.dotcmsSearchControl.getRawValue(), + offset: first, + limit: rows + }) .pipe(take(1)) .subscribe({ - next: (list) => { + next: ({ contentlets, totalRecords }) => { this.zone.run(() => { - this.dotcmsVideos.set(list); + 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); }); 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 index e9cadf1eff4b..8504dfe8f42b 100644 --- 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 @@ -16,6 +16,12 @@ export interface DotCmsContentlet { [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?: { @@ -44,7 +50,7 @@ export class DotCmsContentletService { */ searchImages( params: { text?: string; offset?: number; limit?: number } = {} - ): Observable { + ): Observable { const limit = params.limit ?? 20; const offset = params.offset ?? 0; const raw = params.text?.trim() ?? ''; @@ -61,7 +67,7 @@ export class DotCmsContentletService { */ searchVideos( params: { text?: string; offset?: number; limit?: number } = {} - ): Observable { + ): Observable { const limit = params.limit ?? 20; const offset = params.offset ?? 0; const raw = params.text?.trim() ?? ''; @@ -76,7 +82,7 @@ export class DotCmsContentletService { query: string, limit: number, offset: number - ): Observable { + ): Observable { const headers = new HttpHeaders({ Authorization: `Bearer ${DOT_CMS_AUTH_TOKEN}`, 'Content-Type': 'application/json' @@ -93,7 +99,23 @@ export class DotCmsContentletService { }, { headers } ) - .pipe(map((res) => res.entity?.jsonObjectView?.contentlets ?? [])); + .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 { From b783bc49c4cdb644d7f3c99f381a6520b29e8ef6 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Sun, 12 Apr 2026 22:52:22 -0400 Subject: [PATCH 11/51] feat(new-block-editor): enhance editor functionality --- core-web/libs/new-block-editor/src/feature.md | 13 + .../image/image-dialog.component.ts | 4 +- .../src/lib/editor/editor.component.ts | 146 +++++++-- .../editor/extensions/editor-extensions.ts | 81 +++-- .../editor/slash-menu/slash-menu-catalog.ts | 41 +-- .../editor/slash-menu/slash-menu.component.ts | 15 + .../editor/slash-menu/slash-menu.service.ts | 22 +- .../lib/editor/slash-menu/slash-menu.types.ts | 2 + .../toolbar/editor-toolbar-state.service.ts | 2 + .../lib/editor/toolbar/toolbar.component.ts | 303 +++++++++++++----- 10 files changed, 449 insertions(+), 180 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/feature.md 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..782057b79a9d --- /dev/null +++ b/core-web/libs/new-block-editor/src/feature.md @@ -0,0 +1,13 @@ +### Bugs + +1. No hover style on the image list items sourced from dotCMS +2. The `/` menu scrolls with the content — it appears to be fixed but should stay in place +3. "Link" should not appear as an option in the `/` menu +4. The block editor container should have a fixed height of 500px, support vertical scrolling, and be vertically resizable +5. Only one toolbar dialog/modal/popup should be open at a time — all others must close when clicking outside of them + +### Features + +1. Add an **"Edit Image Properties"** button to the toolbar, allowing users to modify the image URL, title, alt text, and other accessibility attributes. The button should open a form pre-populated with the current image data for editing. +2. The component should accept an **`allowedBlocks`** input (alternatively: `enabledBlocks` or `blockAllowlist`) that determines which block types are available in the editor. This likely requires a block registry map keyed by block name for efficient lookup. +3. Add a **"Full Screen"** button that expands the editor into a dialog covering 90% of the viewport. 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 index 58a39921fcfe..6bf70a8f3473 100644 --- 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 @@ -326,7 +326,7 @@ type Tab = 'upload' | 'url' | 'dotcms'; - + @if (state.isImageSelected()) { + + } - - - - - + @if (showBlockFormatsGroup()) { + + + + @if (isAllowed('bulletList')) { + + } + @if (isAllowed('orderedList')) { + + } + @if (isAllowed('blockquote')) { + + } + @if (isAllowed('codeBlock')) { + + } + } @@ -168,51 +196,78 @@ import { EmojiPickerService } from '../emoji-menu/emoji-picker.service'; - + @if (isAllowed('horizontalRule')) { + + } - + @if (showInsertGroup()) { + + + + @if (isAllowed('link')) { + + } + @if (isAllowed('image')) { + + } + @if (isAllowed('video')) { + + } + @if (isAllowed('table')) { + + } + @if (isAllowed('emoji')) { + + } + } - - - - - + ` }) @@ -225,6 +280,9 @@ export class ToolbarComponent implements OnDestroy { private readonly emojiPickerService = inject(EmojiPickerService); readonly editor = input.required(); + readonly allowedBlocks = input(); + readonly isFullscreen = input(false); + readonly fullscreenToggle = output(); private cleanupFn: (() => void) | null = null; @@ -241,7 +299,7 @@ export class ToolbarComponent implements OnDestroy { protected btnClass(active: boolean): string { const base = - 'flex h-7 w-7 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'; + '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`; @@ -252,6 +310,41 @@ export class ToolbarComponent implements OnDestroy { 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 { @@ -327,6 +420,16 @@ export class ToolbarComponent implements OnDestroy { 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 { @@ -336,6 +439,7 @@ export class ToolbarComponent implements OnDestroy { this.linkDialogService.close(); return; } + this.closeAllDialogs(); const editor = this.editor(); const { from, to, empty } = editor.state.selection; const btn = event.currentTarget as HTMLElement; @@ -403,6 +507,7 @@ export class ToolbarComponent implements OnDestroy { this.imageDialogService.close(); return; } + this.closeAllDialogs(); const btn = event.currentTarget as HTMLElement; const editor = this.editor(); this.imageDialogService.open( @@ -424,6 +529,7 @@ export class ToolbarComponent implements OnDestroy { this.videoDialogService.close(); return; } + this.closeAllDialogs(); const btn = event.currentTarget as HTMLElement; const editor = this.editor(); this.videoDialogService.open( @@ -445,6 +551,7 @@ export class ToolbarComponent implements OnDestroy { this.tableDialogService.close(); return; } + this.closeAllDialogs(); const btn = event.currentTarget as HTMLElement; const editor = this.editor(); this.tableDialogService.open( @@ -462,6 +569,7 @@ export class ToolbarComponent implements OnDestroy { this.emojiPickerService.close(); return; } + this.closeAllDialogs(); const btn = event.currentTarget as HTMLElement; this.emojiPickerService.open( (emoji) => this.editor().chain().focus().insertContent(emoji).run(), @@ -469,6 +577,41 @@ export class ToolbarComponent implements OnDestroy { ); } + // ── 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 domNode = editor.view.nodeDOM(from) as HTMLElement | null; + this.closeAllDialogs(); + this.imageDialogService.open( + (src, title, alt) => { + editor + .chain() + .focus() + .updateAttributes('image', { + src, + title: title || null, + alt: alt || null + }) + .run(); + }, + () => domNode?.getBoundingClientRect() ?? new DOMRect(), + { + src: node.attrs['src'], + title: node.attrs['title'] ?? '', + alt: node.attrs['alt'] ?? '' + } + ); + } + // ── Keyboard navigation (roving tabindex) ──────────────────────────────── protected onToolbarKeyDown(event: KeyboardEvent): void { From 363f667e09bb0e51cb6451325c700aa4829b5de3 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Mon, 13 Apr 2026 11:57:32 -0400 Subject: [PATCH 12/51] feat(new-block-editor): add text image wrap and image property --- .../src/lib/editor/editor-chrome-click.ts | 71 +++++++------- .../src/lib/editor/editor.component.ts | 23 +++++ .../editor/extensions/editor-extensions.ts | 4 +- .../lib/editor/extensions/image.extension.ts | 95 +++++++++++++++++++ .../toolbar/editor-toolbar-state.service.ts | 6 ++ .../lib/editor/toolbar/toolbar.component.ts | 50 +++++++--- 6 files changed, 200 insertions(+), 49 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/image.extension.ts 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 index 799d7d6ca8bd..32092f7fb3a2 100644 --- 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 @@ -10,43 +10,44 @@ import type { LinkDialogService } from './components/link/link-dialog.service'; export function handleEditorProseMirrorClick( event: MouseEvent, editor: Editor, - imageDialog: ImageDialogService, + _imageDialog: ImageDialogService, linkDialog: LinkDialogService ): void { - 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; - } + // 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; 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 index e8788cbd0a85..6c059af8b60b 100644 --- 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 @@ -100,6 +100,29 @@ import { ToolbarComponent } from './toolbar/toolbar.component'; 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; + } ` }) export class EditorComponent implements OnDestroy { 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 index 568d1e9ee9e6..f6666dcde20e 100644 --- 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 @@ -1,7 +1,6 @@ import type { Extensions } from '@tiptap/core'; import CharacterCount from '@tiptap/extension-character-count'; import Emoji, { emojis } from '@tiptap/extension-emoji'; -import Image from '@tiptap/extension-image'; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import { TableKit } from '@tiptap/extension-table'; @@ -9,6 +8,7 @@ import StarterKit from '@tiptap/starter-kit'; import { createBlockGutterDragHandle } from './block-gutter.extension'; import { DotContentlet } from './contentlet.extension'; +import { DotImage } from './image.extension'; import { createSlashCommandExtension } from './slash-command.extension'; import { UploadPlaceholderExtension } from './upload-placeholder.extension'; import { Video } from './video.extension'; @@ -37,7 +37,7 @@ export function createEditorExtensions( createBlockGutterDragHandle(), CharacterCount, ...(has('table') ? [TableKit] : []), - ...(has('image') ? [Image] : []), + ...(has('image') ? [DotImage] : []), ...(has('link') ? [ Link.configure({ 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..5df476df3e41 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/image.extension.ts @@ -0,0 +1,95 @@ +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 }; + }; + }, + + 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/toolbar/editor-toolbar-state.service.ts b/core-web/libs/new-block-editor/src/lib/editor/toolbar/editor-toolbar-state.service.ts index f258b42e076c..658ecafcdd69 100644 --- 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 @@ -21,6 +21,7 @@ export class EditorToolbarStateService { readonly canIndent = signal(false); readonly canOutdent = signal(false); readonly isImageSelected = signal(false); + readonly imageTextWrap = signal(null); connect(editor: Editor): () => void { const update = () => { @@ -35,6 +36,11 @@ export class EditorToolbarStateService { 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')); 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 index 9afcb322c7e4..994ebd7d7279 100644 --- 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 @@ -103,16 +103,36 @@ import { EmojiPickerService } from '../emoji-menu/emoji-picker.service'; - @if (state.isImageSelected()) { - - } + + + @if (showBlockFormatsGroup()) { @@ -577,6 +597,12 @@ export class ToolbarComponent implements OnDestroy { ); } + // ── 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 { @@ -589,7 +615,7 @@ export class ToolbarComponent implements OnDestroy { const node = editor.state.doc.nodeAt(from); if (!node || node.type.name !== 'image') return; - const domNode = editor.view.nodeDOM(from) as HTMLElement | null; + const btn = event.currentTarget as HTMLElement; this.closeAllDialogs(); this.imageDialogService.open( (src, title, alt) => { @@ -603,7 +629,7 @@ export class ToolbarComponent implements OnDestroy { }) .run(); }, - () => domNode?.getBoundingClientRect() ?? new DOMRect(), + () => btn.getBoundingClientRect(), { src: node.attrs['src'], title: node.attrs['title'] ?? '', From 2b72b03197fd1ed60602ec09272359f50055603e Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Mon, 13 Apr 2026 12:34:50 -0400 Subject: [PATCH 13/51] feat(new-block-editor): enhance block editor with new grid functionality and image properties form --- core-web/.claude/settings.json | 3 +++ core-web/libs/new-block-editor/src/feature.md | 21 +++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) 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/libs/new-block-editor/src/feature.md b/core-web/libs/new-block-editor/src/feature.md index 782057b79a9d..d7fef684c3b3 100644 --- a/core-web/libs/new-block-editor/src/feature.md +++ b/core-web/libs/new-block-editor/src/feature.md @@ -1,13 +1,12 @@ -### Bugs - -1. No hover style on the image list items sourced from dotCMS -2. The `/` menu scrolls with the content — it appears to be fixed but should stay in place -3. "Link" should not appear as an option in the `/` menu -4. The block editor container should have a fixed height of 500px, support vertical scrolling, and be vertically resizable -5. Only one toolbar dialog/modal/popup should be open at a time — all others must close when clicking outside of them - ### Features -1. Add an **"Edit Image Properties"** button to the toolbar, allowing users to modify the image URL, title, alt text, and other accessibility attributes. The button should open a form pre-populated with the current image data for editing. -2. The component should accept an **`allowedBlocks`** input (alternatively: `enabledBlocks` or `blockAllowlist`) that determines which block types are available in the editor. This likely requires a block registry map keyed by block name for efficient lookup. -3. Add a **"Full Screen"** button that expands the editor into a dialog covering 90% of the viewport. +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 From bc10a129f55c8d9048620a61e8b9390418416196 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 15 Apr 2026 12:50:07 -0400 Subject: [PATCH 14/51] feat(Block Edito): Grid block, Control Value Accesor, selected style for assets, etc --- .../components/link/link-dialog.component.ts | 29 ++++- .../components/link/link-dialog.service.ts | 19 ++- .../src/lib/editor/editor.component.ts | 73 ++++++++++- .../editor/extensions/contentlet.extension.ts | 110 +++++++++++++++++ .../editor/extensions/editor-extensions.ts | 2 + .../editor/extensions/grid-block.extension.ts | 113 ++++++++++++++++++ .../lib/editor/extensions/image.extension.ts | 10 +- .../lib/editor/extensions/video.extension.ts | 26 ++++ .../editor/slash-menu/slash-menu-catalog.ts | 8 ++ .../lib/editor/toolbar/toolbar.component.ts | 20 +++- 10 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/grid-block.extension.ts 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 index f448de2fa286..90800c9228fc 100644 --- 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 @@ -15,12 +15,14 @@ import { } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { CheckboxModule } from 'primeng/checkbox'; + import { LinkDialogService } from './link-dialog.service'; @Component({ selector: 'dot-block-editor-link-dialog', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, CheckboxModule], 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', @@ -61,6 +63,16 @@ import { LinkDialogService } from './link-dialog.service'; class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" /> +
+ +
+
-
- -
+
+ + + + + + + + + + + @if (showBlockFormatsGroup()) { @@ -305,6 +368,7 @@ export class ToolbarComponent implements OnDestroy { readonly editor = input.required(); readonly isFullscreen = input(false); readonly fullscreenToggle = output(); + readonly contentletEdit = output(); private cleanupFn: (() => void) | null = null; @@ -597,6 +661,29 @@ export class ToolbarComponent implements OnDestroy { ); } + // ── Text alignment ─────────────────────────────────────────────────────── + + protected setTextAlign(align: string): void { + this.editor().chain().focus().setTextAlign(align).run(); + } + + // ── Superscript / Subscript ────────────────────────────────────────────── + + protected toggleSuperscript(): void { + this.editor().chain().focus().toggleSuperscript().run(); + } + + protected toggleSubscript(): void { + this.editor().chain().focus().toggleSubscript().run(); + } + + // ── Edit contentlet ────────────────────────────────────────────────────── + + protected editContentlet(): void { + const data = this.state.selectedContentlet(); + if (data) this.contentletEdit.emit(data); + } + // ── Image text wrap ────────────────────────────────────────────────────── protected setImageWrap(value: 'left' | 'right'): void { @@ -613,7 +700,7 @@ export class ToolbarComponent implements OnDestroy { const { from } = editor.state.selection; const node = editor.state.doc.nodeAt(from); - if (!node || node.type.name !== 'image') return; + if (!node || node.type.name !== 'dotImage') return; const btn = event.currentTarget as HTMLElement; this.closeAllDialogs(); @@ -622,7 +709,7 @@ export class ToolbarComponent implements OnDestroy { editor .chain() .focus() - .updateAttributes('image', { + .updateAttributes('dotImage', { src, title: title || null, alt: alt || null From 6dbdd6162be5d64d9af7f3bc304da52297b55620 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 22 Apr 2026 11:44:47 -0400 Subject: [PATCH 19/51] docs(new-block-editor): add guidelines for immutable TipTap node names to prevent data loss --- core-web/libs/new-block-editor/CLAUDE.md | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md index 2d0bb6952e34..09ea08df8d36 100644 --- a/core-web/libs/new-block-editor/CLAUDE.md +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -24,6 +24,44 @@ Your responses should focus on: - **Major risks** - **Recommended next move** +## TipTap Node Names Are Immutable + +TipTap serializes editor content to JSON using the node's `name` as the `type` key: + +```json +{ "type": "dotImage", "attrs": { ... } } +{ "type": "dotContent", "attrs": { ... } } +``` + +dotCMS customers store this JSON in their database. **If a node name changes, TipTap will not recognize stored content and will silently drop those blocks on load — permanently destroying customer data.** + +### Rule + +**Never rename an existing node's `name` field without explicit approval from the developer.** This applies to any `.extension.ts` or node file where `name:` is set. + +If asked to rename a node, you must: +1. Refuse and explain the data-loss risk +2. Present the trade-off: renaming requires a database migration to rewrite every stored document that contains that node type — not just a code change +3. Wait for explicit developer confirmation before proceeding + +### Creating new nodes + +When creating a new node, you may choose any name — but choose carefully, because **that name can never be changed** once real content has been written with it. Prefer descriptive, namespaced names (e.g. `dotVideo`, `dotContent`) over generic ones. + +### Current node name registry + +| Node | Name | File | +|------|------|------| +| Image | `dotImage` | `extensions/image.extension.ts` | +| Video | `dotVideo` | `extensions/video.extension.ts` | +| Contentlet | `dotContent` | `extensions/contentlet.extension.ts` | +| Grid block | `gridBlock` | `extensions/grid.extension.ts` | +| Grid column | `gridColumn` | `extensions/grid.extension.ts` | + +Standard TipTap/StarterKit names (`paragraph`, `heading`, `bulletList`, `orderedList`, `blockquote`, `codeBlock`, `horizontalRule`, `table`, etc.) are owned by TipTap upstream and must not be changed either. + +--- + ## Deferred Refactors ### Floating dialog abstraction From 50a6932fff4c16a8557b3b9190d465c1291ce77a Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 22 Apr 2026 12:56:54 -0400 Subject: [PATCH 20/51] feat(new-block-editor): update styles and enhance media handling with consistent node names --- .../dotcms-block-editor/src/app/app.config.ts | 6 +- .../apps/dotcms-block-editor/src/styles.css | 3 +- .../src/lib/editor/editor.utils.ts | 11 ++- .../editor/slash-menu/slash-menu-catalog.ts | 6 +- .../lib/editor/toolbar/toolbar.component.ts | 69 ++++++++++++++++++- 5 files changed, 83 insertions(+), 12 deletions(-) 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 index b05ceb86b8b3..8427ee94efec 100644 --- a/core-web/apps/dotcms-block-editor/src/app/app.config.ts +++ b/core-web/apps/dotcms-block-editor/src/app/app.config.ts @@ -19,11 +19,7 @@ export const appConfig: ApplicationConfig = { theme: { preset: Lara, options: { - darkModeSelector: '.dark', - cssLayer: { - name: 'primeng', - order: 'tailwind-base, primeng, tailwind-utilities' - } + darkModeSelector: '.dark' } } }) diff --git a/core-web/apps/dotcms-block-editor/src/styles.css b/core-web/apps/dotcms-block-editor/src/styles.css index f433b088eb1b..67217599ead9 100644 --- a/core-web/apps/dotcms-block-editor/src/styles.css +++ b/core-web/apps/dotcms-block-editor/src/styles.css @@ -1,8 +1,7 @@ /* 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) ──────────────────────── */ 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 index f7ca327b5831..312d12abbdf1 100644 --- 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 @@ -2,11 +2,13 @@ import { Editor } from '@tiptap/core'; import { Slice } from '@tiptap/pm/model'; import { EditorView } from '@tiptap/pm/view'; +import { DOT_IMAGE_NODE_NAME } from './extensions/image.extension'; import { insertUploadPlaceholders, replacePlaceholder, removePlaceholder } from './extensions/upload-placeholder.extension'; +import { DOT_VIDEO_NODE_NAME } from './extensions/video.extension'; export function handleMediaDrop( editor: Editor, @@ -50,7 +52,7 @@ export function handleMediaDrop( uploadImage(file) .then((src) => replacePlaceholder(editor, id, { - type: 'image', + type: DOT_IMAGE_NODE_NAME, attrs: { src, alt: file.name } }) ) @@ -62,7 +64,7 @@ export function handleMediaDrop( const reader = new FileReader(); reader.onload = () => { replacePlaceholder(editor, id, { - type: 'image', + type: DOT_IMAGE_NODE_NAME, attrs: { src: reader.result as string, alt: file.name } }); }; @@ -78,7 +80,10 @@ export function handleMediaDrop( uploadVideo(file) .then((src) => { const title = file.name.replace(/\.[^.]+$/, ''); - replacePlaceholder(editor, id, { type: 'video', attrs: { src, title } }); + replacePlaceholder(editor, id, { + type: DOT_VIDEO_NODE_NAME, + attrs: { src, title } + }); }) .catch((err) => { console.error('Video drop upload failed', err); 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 index c2869a5b2bd9..b6490e70b74d 100644 --- 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 @@ -4,6 +4,7 @@ import type { Editor } from '@tiptap/core'; import { SuggestionPluginKey } from '@tiptap/suggestion'; import { DOT_CONTENTLET_NODE_NAME } from '../extensions/contentlet.extension'; +import { DOT_VIDEO_NODE_NAME } from '../extensions/video.extension'; import type { BlockItem } from './slash-menu.types'; import type { ImageDialogService } from '../components/image/image-dialog.service'; @@ -340,7 +341,10 @@ export function createSlashDialogBlockItems(services: SlashDialogServices): Bloc editor .chain() .focus() - .insertContent({ type: 'video', attrs: { src, title: title ?? null } }) + .insertContent({ + type: DOT_VIDEO_NODE_NAME, + attrs: { src, title: title ?? null } + }) .run(); }, () => rect 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 index 406f487171e2..5ad6400cf6db 100644 --- 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 @@ -9,6 +9,8 @@ import { output } from '@angular/core'; +import { Tooltip } from 'primeng/tooltip'; + import { Editor } from '@tiptap/core'; import { EditorToolbarStateService } from './editor-toolbar-state.service'; @@ -18,6 +20,7 @@ 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'; +import { DOT_VIDEO_NODE_NAME } from '../extensions/video.extension'; import { EditorStore } from '../store/editor.store'; import type { ContentletEditEvent } from '../extensions/contentlet.extension'; @@ -25,6 +28,7 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; @Component({ selector: 'dot-block-editor-toolbar', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Tooltip], host: { role: 'toolbar', 'aria-label': 'Text formatting', @@ -39,6 +43,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; [disabled]="!state.canUndo()" [attr.aria-disabled]="!state.canUndo()" aria-label="Undo" + pTooltip="Undo" + tooltipPosition="bottom" [class]="btnClass(false)" (click)="undo()"> @@ -48,6 +54,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; [disabled]="!state.canRedo()" [attr.aria-disabled]="!state.canRedo()" aria-label="Redo" + pTooltip="Redo" + tooltipPosition="bottom" [class]="btnClass(false)" (click)="redo()"> @@ -77,6 +85,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.isBold()" aria-label="Bold" + pTooltip="Bold" + tooltipPosition="bottom" [class]="btnClass(state.isBold())" (click)="toggleBold()"> @@ -85,6 +95,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.isItalic()" aria-label="Italic" + pTooltip="Italic" + tooltipPosition="bottom" [class]="btnClass(state.isItalic())" (click)="toggleItalic()"> @@ -93,6 +105,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.isStrike()" aria-label="Strikethrough" + pTooltip="Strikethrough" + tooltipPosition="bottom" [class]="btnClass(state.isStrike())" (click)="toggleStrike()"> @@ -101,6 +115,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.isCode()" aria-label="Inline code" + pTooltip="Inline code" + tooltipPosition="bottom" [class]="btnClass(state.isCode())" (click)="toggleCode()"> @@ -109,6 +125,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.isSuperscript()" aria-label="Superscript" + pTooltip="Superscript" + tooltipPosition="bottom" [class]="btnClass(state.isSuperscript())" (click)="toggleSuperscript()"> @@ -117,6 +135,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.isSubscript()" aria-label="Subscript" + pTooltip="Subscript" + tooltipPosition="bottom" [class]="btnClass(state.isSubscript())" (click)="toggleSubscript()"> @@ -129,6 +149,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.textAlign() === 'left'" aria-label="Align left" + pTooltip="Align left" + tooltipPosition="bottom" [class]="btnClass(state.textAlign() === 'left')" (click)="setTextAlign('left')"> @@ -137,6 +159,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.textAlign() === 'center'" aria-label="Align center" + pTooltip="Align center" + tooltipPosition="bottom" [class]="btnClass(state.textAlign() === 'center')" (click)="setTextAlign('center')"> @@ -145,6 +169,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.textAlign() === 'right'" aria-label="Align right" + pTooltip="Align right" + tooltipPosition="bottom" [class]="btnClass(state.textAlign() === 'right')" (click)="setTextAlign('right')"> @@ -153,6 +179,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension'; type="button" [attr.aria-pressed]="state.textAlign() === 'justify'" aria-label="Justify" + pTooltip="Justify" + tooltipPosition="bottom" [class]="btnClass(state.textAlign() === 'justify')" (click)="setTextAlign('justify')"> @@ -161,6 +189,8 @@ import type { ContentletEditEvent } from '../extensions/contentlet.extension';
- @if (dotcmsError()) { - + @if (dotcmsPicker.error()) { + } @else {
('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); + protected readonly dotcmsPicker = signalState({ + images: [], + loading: false, + error: null, + totalRecords: 0, + first: 0, + pageSize: 8 + }); readonly dotcmsRows = 8; readonly dotcmsRowsOptions: number[] = [8, 16, 24]; @@ -437,20 +422,9 @@ export class ImageDialogComponent { const isOpen = this.service.isOpen(); const clientRectFn = this.service.clientRectFn(); + // Closed or not yet anchored: clear state so the next open does not show stale UI. 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: '' }); - }); + untracked(() => this.resetDialogUiForClosedOrUnpositioned()); return; } @@ -480,6 +454,7 @@ export class ImageDialogComponent { }); } + /** CSS classes for a create-mode tab button (upload / URL / dotCMS). */ 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'; @@ -488,28 +463,38 @@ export class ImageDialogComponent { : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`; } + /** Thumbnail URL for a dotCMS asset inode (fixed max dimension for list rows). */ dotcmsThumbUrl(inode: string): string { return `${DOT_CMS_BASE_URL}/dA/${inode}/120/max`; } + /** Switches create-mode UI to the dotCMS image picker tab. */ onSelectDotcmsTab(): void { this.activeTab.set('dotcms'); } + /** PrimeNG DataView lazy page: updates page size and loads that slice from dotCMS. */ onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void { - this.dotcmsPageSize.set(event.rows); + patchState(this.dotcmsPicker, { pageSize: event.rows }); this.fetchDotcmsImagesPage(event.first, event.rows); } - /** New search/filter: reset to first page (keeps current rows-per-page). */ + /** + * Runs a dotCMS image search with the current filter text. + * Resets to the first page while keeping the current rows-per-page. + */ runDotcmsSearch(): void { - this.dotcmsFirst.set(0); - this.fetchDotcmsImagesPage(0, this.dotcmsPageSize()); + patchState(this.dotcmsPicker, { first: 0 }); + this.fetchDotcmsImagesPage(0, this.dotcmsPicker.pageSize()); } + /** + * Loads one page of image contentlets from dotCMS into {@link dotcmsPicker}. + * @param first Row offset for the API (matches DataView `first`). + * @param rows Page size (limit). + */ private fetchDotcmsImagesPage(first: number, rows: number): void { - this.dotcmsLoading.set(true); - this.dotcmsError.set(null); + patchState(this.dotcmsPicker, { loading: true, error: null }); this.dotCmsContentlet .searchImages({ text: this.dotcmsSearchControl.getRawValue(), @@ -521,52 +506,94 @@ export class ImageDialogComponent { .subscribe({ next: ({ contentlets, totalRecords }) => { this.zone.run(() => { - this.dotcmsImages.set(contentlets); - this.dotcmsTotalRecords.set(totalRecords); - this.dotcmsFirst.set(first); - this.dotcmsLoading.set(false); + patchState(this.dotcmsPicker, { + images: contentlets, + totalRecords, + first, + loading: 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); + patchState(this.dotcmsPicker, { + images: [], + totalRecords: 0, + error: 'Could not load images from dotCMS.', + loading: false + }); }); } }); } + /** Inserts the selected dotCMS image into the document with full `DotImageData` metadata. */ 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); + const data: DotImageData = { + identifier: contentlet.identifier, + inode: contentlet.inode, + languageId: contentlet.languageId, + title: contentlet.title ?? '', + asset: `/dA/${contentlet.inode}` + }; + this.service.insert(src, label || undefined, label || undefined, data); } + /** + * Handles file input on the upload tab. + * Inserts a placeholder immediately (dialog closes), uploads in the background, + * then replaces the placeholder with the real image node. + */ async onFileChange(event: Event): Promise { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; - this.uploading.set(true); + const placeholderId = this.service.startUpload(); + if (!placeholderId) return; + try { - const src = await this.dotCmsUpload.uploadImage(file); - this.zone.run(() => this.service.insert(src, undefined, file.name)); + const { src, data } = await this.dotCmsUpload.uploadImage(file); + this.zone.run(() => + this.service.finishUpload(placeholderId, { src, alt: file.name, data }) + ); } catch (err) { console.error('Image upload failed', err); - } finally { - this.uploading.set(false); + this.service.cancelUpload(placeholderId); } } + /** Inserts an image from the URL tab when the URL control is valid. */ onInsertUrl(): void { if (this.urlControl.invalid) return; this.service.insert(this.urlControl.getRawValue()); } + /** Persists edit-mode changes (src, tooltip, alt) back into the document. */ onApplyEdit(): void { if (this.editForm.controls.src.invalid) return; const { src, title, alt } = this.editForm.getRawValue(); this.service.insert(src, title || undefined, alt || undefined); } + + /** + * Resets dialog UI when the panel is closed or cannot be anchored yet (`clientRectFn` missing). + * Ensures the next open does not leak tab choice, URL/search/edit values, or dotCMS list state. + */ + private resetDialogUiForClosedOrUnpositioned(): void { + this.positioned.set(false); + this.activeTab.set('url'); + this.urlControl.reset(''); + this.dotcmsSearchControl.reset(''); + patchState(this.dotcmsPicker, { + images: [], + loading: false, + error: null, + totalRecords: 0, + first: 0, + pageSize: this.dotcmsRows + }); + this.editForm.reset({ src: '', title: '', alt: '' }); + } } 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 index a6cbdf3fea80..1d1644c84a5b 100644 --- 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 @@ -1,8 +1,23 @@ import { Injectable, signal } from '@angular/core'; +import { type DotImageData } from '../../extensions/image.extension'; import { FloatingBlockDialogService } from '../floating-block-dialog.base'; -export type InsertImageFn = (src: string, title?: string, alt?: string) => void; +export type InsertImageFn = ( + src: string, + title?: string, + alt?: string, + data?: DotImageData +) => void; + +export interface ImageUploadCallbacks { + /** Called immediately when the user picks a file — inserts a placeholder and returns its id. */ + onStart: () => string; + /** Called after a successful upload — replaces the placeholder with the real image node. */ + onFinish: (id: string, attrs: { src: string; alt?: string; data?: DotImageData }) => void; + /** Called after a failed upload — removes the placeholder. */ + onCancel: (id: string) => void; +} export interface ImageInitialValues { src: string; @@ -10,32 +25,73 @@ export interface ImageInitialValues { alt: string; } +export interface ImageOpenOptions { + uploadCallbacks?: ImageUploadCallbacks; + initialValues?: ImageInitialValues; +} + @Injectable({ providedIn: 'root' }) export class ImageDialogService extends FloatingBlockDialogService { readonly initialValues = signal(null); private insertFn: InsertImageFn | null = null; + private uploadCallbacks: ImageUploadCallbacks | null = null; + + // Saved across close() so async upload lifecycle can still call finish/cancel + private pendingFinish: ImageUploadCallbacks['onFinish'] | null = null; + private pendingCancel: ImageUploadCallbacks['onCancel'] | null = null; open( insertFn: InsertImageFn, clientRectFn: () => DOMRect | null, - initialValues?: ImageInitialValues + options?: ImageOpenOptions ): void { this.openFloating(clientRectFn, () => { this.insertFn = insertFn; - this.initialValues.set(initialValues ?? null); + this.uploadCallbacks = options?.uploadCallbacks ?? null; + this.initialValues.set(options?.initialValues ?? null); }); } - insert(src: string, title?: string, alt?: string): void { - this.insertFn?.(src, title, alt); + insert(src: string, title?: string, alt?: string, data?: DotImageData): void { + this.insertFn?.(src, title, alt, data); + this.close(); + } + + /** + * Inserts a placeholder at the cursor position and immediately closes the dialog. + * The upload lifecycle callbacks are saved before close() clears dialog state. + * Returns the placeholder id — pass it to `finishUpload` or `cancelUpload`. + * Returns null if no upload callbacks were registered (e.g. edit mode). + */ + startUpload(): string | null { + if (!this.uploadCallbacks) return null; + // Capture before close() wipes uploadCallbacks + const { onStart, onFinish, onCancel } = this.uploadCallbacks; + this.pendingFinish = onFinish; + this.pendingCancel = onCancel; + const id = onStart(); this.close(); + return id; + } + + finishUpload(id: string, attrs: { src: string; alt?: string; data?: DotImageData }): void { + this.pendingFinish?.(id, attrs); + this.pendingFinish = null; + this.pendingCancel = null; + } + + cancelUpload(id: string): void { + this.pendingCancel?.(id); + this.pendingFinish = null; + this.pendingCancel = null; } close(): void { this.closeFloating(() => { this.initialValues.set(null); this.insertFn = null; + this.uploadCallbacks = null; }); } } 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 index 312d12abbdf1..0670d872cd18 100644 --- 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 @@ -9,6 +9,7 @@ import { removePlaceholder } from './extensions/upload-placeholder.extension'; import { DOT_VIDEO_NODE_NAME } from './extensions/video.extension'; +import { type UploadedImage } from './services/dot-cms-upload.service'; export function handleMediaDrop( editor: Editor, @@ -16,7 +17,7 @@ export function handleMediaDrop( event: DragEvent, _slice: Slice, moved: boolean, - uploadImage?: (file: File) => Promise, + uploadImage?: (file: File) => Promise, uploadVideo?: (file: File) => Promise ): boolean { if (moved) return false; @@ -50,10 +51,10 @@ export function handleMediaDrop( if (uploadImage) { uploadImage(file) - .then((src) => + .then(({ src, data }) => replacePlaceholder(editor, id, { type: DOT_IMAGE_NODE_NAME, - attrs: { src, alt: file.name } + attrs: { src, alt: file.name, data } }) ) .catch((err) => { 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 index 2f648b73660e..dc3055d74a27 100644 --- 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 @@ -4,6 +4,19 @@ import Image from '@tiptap/extension-image'; /** TipTap node name for embedded dotCMS images (slash menu → image). */ export const DOT_IMAGE_NODE_NAME = 'dotImage' as const; +export interface DotImageData { + identifier: string; + inode: string; + languageId: number; + title: string; + asset: string; +} + +function appendLanguageId(src: string, languageId: number | undefined): string { + if (!src || !languageId) return src; + return src.includes('language_id') ? src : `${src}?language_id=${languageId}`; +} + declare module '@tiptap/core' { interface Commands { dotImage: { @@ -19,6 +32,44 @@ export const DotImage = Image.extend({ addAttributes() { return { ...this.parent?.(), + src: { + default: null, + parseHTML: (element) => element.getAttribute('src'), + renderHTML: (attributes) => ({ + src: appendLanguageId( + attributes.src || attributes.data?.asset, + attributes.data?.languageId + ) + }) + }, + alt: { + default: null, + parseHTML: (element) => element.getAttribute('alt'), + renderHTML: (attributes) => ({ + alt: attributes.alt || attributes.data?.title || null + }) + }, + title: { + default: null, + parseHTML: (element) => element.getAttribute('title'), + renderHTML: (attributes) => ({ + title: attributes.title || attributes.data?.title || null + }) + }, + data: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute('data'); + if (!raw) return null; + try { + return JSON.parse(raw) as DotImageData; + } catch { + return null; + } + }, + renderHTML: (attributes) => + attributes.data ? { data: JSON.stringify(attributes.data) } : {} + }, textWrap: { default: null, // Read from the parent
's class — set by renderHTML() 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 index 86c9ddea09e0..ca72c47d6228 100644 --- 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 @@ -13,6 +13,7 @@ export interface DotCmsContentlet { title: string; contentType: string; modDate: string; + languageId: number; [key: string]: unknown; } 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 index c75e06c06bf6..07f1766ad828 100644 --- 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 @@ -5,9 +5,16 @@ import { map, take } from 'rxjs/operators'; import { DOT_CMS_AUTH_TOKEN, DOT_CMS_BASE_URL } from './dot-cms.config'; +import { type DotImageData } from '../extensions/image.extension'; + const BASE_URL = DOT_CMS_BASE_URL; const AUTH_TOKEN = DOT_CMS_AUTH_TOKEN; +export interface UploadedImage { + src: string; + data: DotImageData; +} + @Injectable({ providedIn: 'root' }) export class DotCmsUploadService { private readonly http = inject(HttpClient); @@ -18,20 +25,32 @@ export class DotCmsUploadService { }); } - async uploadImage(file: File): Promise { + async uploadImage(file: File): Promise { return this.uploadAsset(file); } async uploadVideo(file: File): Promise { - return this.uploadAsset(file); + return this.uploadVideoAsset(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 result = await this.publishImageAsset(tempId).pipe(take(1)).toPromise(); + if (result === undefined) { + throw new Error('Publish: no value emitted'); + } + return result; } - private async uploadAsset(file: File): Promise { + private async uploadVideoAsset(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(); + const url = await this.publishVideoAsset(tempId).pipe(take(1)).toPromise(); if (url === undefined) { throw new Error('Publish: no value emitted'); } @@ -55,7 +74,59 @@ export class DotCmsUploadService { ); } - private publishAsset(tempId: string) { + private publishImageAsset(tempId: string) { + interface PublishContentlet { + asset: string; + identifier: string; + inode: string; + languageId: number; + title: 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 PublishContentlet | undefined; + if (!contentlet?.asset) throw new Error('Publish: missing asset path'); + return { + src: `${BASE_URL}${contentlet.asset}`, + data: { + identifier: contentlet.identifier, + inode: contentlet.inode, + languageId: contentlet.languageId, + title: contentlet.title ?? '', + asset: contentlet.asset + } satisfies DotImageData + }; + }) + ); + } + + private publishVideoAsset(tempId: string) { interface PublishBody { entity: { results: Array> }; } 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 index b6490e70b74d..c44d2ecc3127 100644 --- 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 @@ -4,6 +4,12 @@ import type { Editor } from '@tiptap/core'; import { SuggestionPluginKey } from '@tiptap/suggestion'; import { DOT_CONTENTLET_NODE_NAME } from '../extensions/contentlet.extension'; +import { DOT_IMAGE_NODE_NAME } from '../extensions/image.extension'; +import { + insertUploadPlaceholders, + replacePlaceholder, + removePlaceholder +} from '../extensions/upload-placeholder.extension'; import { DOT_VIDEO_NODE_NAME } from '../extensions/video.extension'; import type { BlockItem } from './slash-menu.types'; @@ -315,14 +321,41 @@ export function createSlashDialogBlockItems(services: SlashDialogServices): Bloc const coords = editor.view.coordsAtPos(from); const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); image.open( - (src, title, alt) => { + (src, title, alt, data) => { editor .chain() .focus() - .setImage({ src, title: title || undefined, alt: alt || undefined }) + .insertContent({ + type: DOT_IMAGE_NODE_NAME, + attrs: { + src, + title: title || null, + alt: alt || null, + data: data ?? null + } + }) .run(); }, - () => rect + () => rect, + { + uploadCallbacks: { + onStart: () => { + const pos = editor.state.selection.from; + const id = `img-upload-${Date.now()}`; + insertUploadPlaceholders(editor, pos, [{ id, mediaType: 'image' }]); + return id; + }, + onFinish: (id, attrs) => { + replacePlaceholder(editor, id, { + type: DOT_IMAGE_NODE_NAME, + attrs: { ...attrs, title: null } + }); + }, + onCancel: (id) => { + removePlaceholder(editor, id); + } + } + } ); } }, 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 index 4e116dcfa2b5..13d131809b6c 100644 --- 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 @@ -20,6 +20,12 @@ 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'; +import { DOT_IMAGE_NODE_NAME } from '../extensions/image.extension'; +import { + insertUploadPlaceholders, + replacePlaceholder, + removePlaceholder +} from '../extensions/upload-placeholder.extension'; import { DOT_VIDEO_NODE_NAME } from '../extensions/video.extension'; import { EditorStore } from '../store/editor.store'; @@ -659,14 +665,36 @@ export class ToolbarComponent implements OnDestroy { const btn = event.currentTarget as HTMLElement; const editor = this.editor(); this.imageDialogService.open( - (src, title, alt) => { + (src, title, alt, data) => { editor .chain() .focus() - .setImage({ src, title: title || undefined, alt: alt || undefined }) + .insertContent({ + type: DOT_IMAGE_NODE_NAME, + attrs: { src, title: title || null, alt: alt || null, data: data ?? null } + }) .run(); }, - () => btn.getBoundingClientRect() + () => btn.getBoundingClientRect(), + { + uploadCallbacks: { + onStart: () => { + const pos = editor.state.selection.from; + const id = `img-upload-${Date.now()}`; + insertUploadPlaceholders(editor, pos, [{ id, mediaType: 'image' }]); + return id; + }, + onFinish: (id, attrs) => { + replacePlaceholder(editor, id, { + type: DOT_IMAGE_NODE_NAME, + attrs: { ...attrs, title: null } + }); + }, + onCancel: (id) => { + removePlaceholder(editor, id); + } + } + } ); } @@ -785,9 +813,11 @@ export class ToolbarComponent implements OnDestroy { }, () => btn.getBoundingClientRect(), { - src: node.attrs['src'], - title: node.attrs['title'] ?? '', - alt: node.attrs['alt'] ?? '' + initialValues: { + src: node.attrs['src'], + title: node.attrs['title'] ?? '', + alt: node.attrs['alt'] ?? '' + } } ); } From 03fe36e6c9f1d15ff55b851b61b328dba9862e34 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 23 Apr 2026 13:38:19 -0400 Subject: [PATCH 24/51] feat(new-block-editor): refactor dialog system for improved architecture and state management - Consolidated dialog handling into a single to manage the state and payloads of various dialogs (image, link, video, etc.). - Introduced a new for consistent dialog presentation and behavior, including positioning and visibility management. - Updated dialog components (image, link, table, emoji) to utilize the new dialog system, enhancing maintainability and reducing code duplication. - Removed legacy dialog services to streamline the codebase and improve clarity. --- core-web/libs/new-block-editor/CLAUDE.md | 18 +- .../editor-dialog/editor-dialog.component.ts | 113 +++ .../emoji-menu/emoji-picker.component.ts | 58 ++ .../components/floating-block-dialog.base.ts | 28 - .../image/image-dialog.component.ts | 766 +++++++++--------- .../components/image/image-dialog.service.ts | 97 --- .../components/link/link-dialog.component.ts | 287 +++---- .../components/link/link-dialog.service.ts | 55 -- .../table/table-dialog.component.ts | 229 ++---- .../components/table/table-dialog.service.ts | 32 - .../video/video-dialog.component.ts | 581 +++++++------ .../components/video/video-dialog.service.ts | 27 - .../src/lib/editor/editor-chrome-click.ts | 33 +- .../src/lib/editor/editor.component.ts | 46 +- .../emoji-menu/emoji-picker.component.ts | 117 --- .../editor/emoji-menu/emoji-picker.service.ts | 31 - .../services/editor-dialog-manager.service.ts | 61 ++ .../editor/slash-menu/slash-menu-catalog.ts | 82 +- .../editor/slash-menu/slash-menu.component.ts | 2 +- .../editor/slash-menu/slash-menu.service.ts | 14 +- .../lib/editor/toolbar/toolbar.component.ts | 197 +---- 21 files changed, 1185 insertions(+), 1689 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog/editor-dialog.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/emoji-menu/emoji-picker.component.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.component.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/emoji-menu/emoji-picker.service.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/editor-dialog-manager.service.ts diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md index 09ea08df8d36..a8939d92bc09 100644 --- a/core-web/libs/new-block-editor/CLAUDE.md +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -62,16 +62,14 @@ Standard TipTap/StarterKit names (`paragraph`, `heading`, `bulletList`, `ordered --- -## Deferred Refactors +## Dialog System Architecture -### 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 +All block dialogs (table, image, video, link, emoji) share a single `EditorDialogManagerService` and an `` shell component: -And the same service-level pattern: -- `isOpen` + `clientRectFn` signals -- `zone.run()` wrapping in `open()` / `close()` +- `EditorDialogManagerService` (`services/editor-dialog-manager.service.ts`) — central state: which dialog is open, its anchor rect, and per-dialog payloads (`imagePayload`, `linkPayload`). +- `EditorDialogComponent` (`components/editor-dialog/editor-dialog.component.ts`) — shell wrapper: absolute positioning via `@floating-ui/dom`, `display:none` toggle, Escape + click-outside dismiss, `` projection, `(opened)` output for auto-focus. -**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 +Each dialog content component: +- Takes `editor = input.required()` and calls editor commands directly. +- Wraps its form in `` and uses `(opened)` to auto-focus the first input. +- Injects `EditorDialogManagerService` for open/close state and payloads. \ No newline at end of file diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog/editor-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog/editor-dialog.component.ts new file mode 100644 index 000000000000..c486298a8a24 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog/editor-dialog.component.ts @@ -0,0 +1,113 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + computed, + effect, + inject, + input, + output, + signal, + untracked +} from '@angular/core'; + +import { + EditorDialogManagerService, + type DialogId +} from '../../services/editor-dialog-manager.service'; + +/** + * Shell wrapper for all floating editor dialogs. + * Handles absolute positioning via @floating-ui/dom, visibility, Escape key, and click-outside. + * Dialog content is projected via . + * The (opened) output fires once after the dialog first becomes visible — use it to auto-focus inputs. + */ +@Component({ + selector: 'editor-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'absolute z-50', + '[style.display]': 'isOpen() ? null : "none"', + '[style.visibility]': 'positioned() ? "visible" : "hidden"', + '[style.left.px]': 'floatX()', + '[style.top.px]': 'floatY()' + }, + template: `` +}) +export class EditorDialogComponent { + readonly dialogId = input.required(); + + /** Emits once after the dialog is positioned and visible. Use to auto-focus an input. */ + readonly opened = output(); + + private readonly manager = inject(EditorDialogManagerService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly doc = inject(DOCUMENT); + + protected readonly isOpen = computed(() => this.manager.activeDialog()?.id === this.dialogId()); + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + protected readonly positioned = signal(false); + + constructor() { + // Position the dialog on every render while it is open. + // The wasPositioned guard ensures (opened) fires only on the first render after opening. + afterRenderEffect(() => { + const dialog = this.manager.activeDialog(); + if (!dialog || dialog.id !== this.dialogId()) { + untracked(() => this.positioned.set(false)); + return; + } + const rect = dialog.clientRectFn(); + if (!rect) return; + + computePosition( + { getBoundingClientRect: () => rect }, + this.el.nativeElement, + { + placement: 'bottom-start', + strategy: 'absolute', + middleware: [flip(), shift({ padding: 8 })] + } + ).then(({ x, y }) => { + const wasPositioned = untracked(() => this.positioned()); + this.zone.run(() => { + untracked(() => { + this.floatX.set(x); + this.floatY.set(y); + this.positioned.set(true); + }); + }); + if (!wasPositioned) { + this.opened.emit(); + } + }); + }); + + // Close on Escape or click outside. + effect((onCleanup) => { + if (!this.isOpen()) return; + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.zone.run(() => this.manager.close()); + }; + const onMouse = (e: MouseEvent) => { + if (!this.el.nativeElement.contains(e.target as Node)) { + this.zone.run(() => this.manager.close()); + } + }; + this.doc.addEventListener('keydown', onKey); + this.doc.addEventListener('mousedown', onMouse); + onCleanup(() => { + this.doc.removeEventListener('keydown', onKey); + this.doc.removeEventListener('mousedown', onMouse); + }); + }); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/emoji-menu/emoji-picker.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/emoji-menu/emoji-picker.component.ts new file mode 100644 index 000000000000..92196a0929f4 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/emoji-menu/emoji-picker.component.ts @@ -0,0 +1,58 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + NgZone, + ViewChild, + afterNextRender, + inject, + input +} from '@angular/core'; + +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; + +@Component({ + selector: 'dot-emoji-picker', + changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [EditorDialogComponent], + template: ` + +
+
+ ` +}) +export class EmojiPickerComponent { + readonly editor = input.required(); + + @ViewChild('pickerMount', { read: ElementRef }) pickerMount!: ElementRef; + + private readonly manager = inject(EditorDialogManagerService); + private readonly zone = inject(NgZone); + + 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.editor().chain().focus().insertContent(emoji.native).run(); + this.manager.close(); + }); + } + }); + this.pickerMount.nativeElement.appendChild(picker as unknown as Node); + }); + }); + }); + } +} 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 deleted file mode 100644 index 04a2bad34bc2..000000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 index e9f8de8cea58..14147dee70cb 100644 --- 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 @@ -1,16 +1,14 @@ -import { computePosition, flip, shift } from '@floating-ui/dom'; import { patchState, signalState } from '@ngrx/signals'; -import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, NgZone, - afterRenderEffect, computed, effect, inject, + input, signal, untracked } from '@angular/core'; @@ -20,340 +18,351 @@ import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview'; import { take } from 'rxjs/operators'; -import { ImageDialogService } from './image-dialog.service'; +import { Editor } from '@tiptap/core'; -import { type DotImageData } from '../../extensions/image.extension'; +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; +import { type DotImageData, DOT_IMAGE_NODE_NAME } from '../../extensions/image.extension'; 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'; +import { + insertUploadPlaceholders, + replacePlaceholder, + removePlaceholder +} from '../../extensions/upload-placeholder.extension'; import { EditorStore } from '../../store/editor.store'; type Tab = 'upload' | 'url' | 'dotcms'; -/** Lazy-loaded dotCMS image list, pagination, and request status for the image dialog. */ interface DotcmsImagePickerState { images: DotCmsContentlet[]; loading: boolean; error: string | null; totalRecords: number; first: number; - /** Last page size from DataView (rows per page); used when Search resets to page 0. */ pageSize: number; } @Component({ - selector: 'dot-block-editor-image-dialog', + selector: 'dot-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()' - }, + imports: [ReactiveFormsModule, DataViewModule, EditorDialogComponent], template: ` - @if (isEditing()) { - +
-
- - -
- -
- -

- Text shown when hovering over the image -

- -
- -
- -

- Read aloud by screen readers; improves accessibility -

- -
- -
- - -
-
- } @else { - -
- - - -
- - @if (activeTab() === 'upload') { -
- -
- } + [attr.aria-label]="isEditing() ? 'Edit image' : 'Insert image'" + class="w-[32rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg"> + @if (isEditing()) { + +
+
+ + +
- @if (activeTab() === 'url') { -
- - -
- -
-
- } +
+ +

+ Text shown when hovering over the image +

+ +
- @if (activeTab() === 'dotcms') { -
-
- -
+
+ +

+ Read aloud by screen readers; improves accessibility +

+ id="edit-img-alt" + type="text" + [formControl]="editForm.controls.alt" + placeholder="Describe what's in the image…" + aria-describedby="edit-img-alt-hint" + class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" /> +
+ +
+
+ } @else { + +
+ + + +
- @if (dotcmsPicker.error()) { - - } @else { + @if (activeTab() === 'upload') { +
+ +
+ } + + @if (activeTab() === 'url') {
- - -
- @for (img of items; track img.inode) { - - } -
-
-
+ class="p-4 flex flex-col gap-3" + (keydown.enter)="$event.preventDefault(); onInsertUrl()"> + + +
+ +
} -
- } - } + + @if (activeTab() === 'dotcms') { +
+
+ +
+ + +
+
+ + @if (dotcmsPicker.error()) { + + } @else { +
+ + +
+ @for (img of items; track img.inode) { + + } +
+
+
+
+ } +
+ } + } +
+ ` }) export class ImageDialogComponent { - protected readonly service = inject(ImageDialogService); + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); 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); private readonly store = inject(EditorStore); - 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 isEditing = computed( + () => this.manager.imagePayload()?.initialValues != null + ); protected readonly dotcmsPicker = signalState({ images: [], loading: false, @@ -365,15 +374,11 @@ export class ImageDialogComponent { 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 }), @@ -381,80 +386,31 @@ export class ImageDialogComponent { }); constructor() { + // Pre-populate the edit form when opened in edit mode. effect(() => { - const values = this.service.initialValues(); + const values = this.manager.imagePayload()?.initialValues; if (values) { untracked(() => - this.editForm.setValue({ - src: values.src, - title: values.title, - alt: values.alt - }) + 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(); - - // Closed or not yet anchored: clear state so the next open does not show stale UI. - if (!isOpen || !clientRectFn) { - untracked(() => this.resetDialogUiForClosedOrUnpositioned()); - return; + // Reset dialog UI state when the dialog closes. + effect(() => { + if (!this.manager.isOpen('image')) { + untracked(() => this.resetDialogUi()); } - - 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); - }); }); } - /** CSS classes for a create-mode tab button (upload / URL / dotCMS). */ + protected focusFirst(): void { + const input = this.el.nativeElement.querySelector( + 'input:not([type="file"]):not([type="checkbox"])' + ) as HTMLElement | null; + input?.focus(); + } + 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'; @@ -463,36 +419,24 @@ export class ImageDialogComponent { : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`; } - /** Thumbnail URL for a dotCMS asset inode (fixed max dimension for list rows). */ dotcmsThumbUrl(inode: string): string { return `${DOT_CMS_BASE_URL}/dA/${inode}/120/max`; } - /** Switches create-mode UI to the dotCMS image picker tab. */ onSelectDotcmsTab(): void { this.activeTab.set('dotcms'); } - /** PrimeNG DataView lazy page: updates page size and loads that slice from dotCMS. */ onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void { patchState(this.dotcmsPicker, { pageSize: event.rows }); this.fetchDotcmsImagesPage(event.first, event.rows); } - /** - * Runs a dotCMS image search with the current filter text. - * Resets to the first page while keeping the current rows-per-page. - */ runDotcmsSearch(): void { patchState(this.dotcmsPicker, { first: 0 }); this.fetchDotcmsImagesPage(0, this.dotcmsPicker.pageSize()); } - /** - * Loads one page of image contentlets from dotCMS into {@link dotcmsPicker}. - * @param first Row offset for the API (matches DataView `first`). - * @param rows Page size (limit). - */ private fetchDotcmsImagesPage(first: number, rows: number): void { patchState(this.dotcmsPicker, { loading: true, error: null }); this.dotCmsContentlet @@ -505,29 +449,28 @@ export class ImageDialogComponent { .pipe(take(1)) .subscribe({ next: ({ contentlets, totalRecords }) => { - this.zone.run(() => { + this.zone.run(() => patchState(this.dotcmsPicker, { images: contentlets, totalRecords, first, loading: false - }); - }); + }) + ); }, error: () => { - this.zone.run(() => { + this.zone.run(() => patchState(this.dotcmsPicker, { images: [], totalRecords: 0, error: 'Could not load images from dotCMS.', loading: false - }); - }); + }) + ); } }); } - /** Inserts the selected dotCMS image into the document with full `DotImageData` metadata. */ insertFromDotcms(contentlet: DotCmsContentlet): void { const src = `${DOT_CMS_BASE_URL}/dA/${contentlet.inode}`; const label = contentlet.title || contentlet.identifier; @@ -538,51 +481,84 @@ export class ImageDialogComponent { title: contentlet.title ?? '', asset: `/dA/${contentlet.inode}` }; - this.service.insert(src, label || undefined, label || undefined, data); + this.editor() + .chain() + .focus() + .insertContent({ + type: DOT_IMAGE_NODE_NAME, + attrs: { + src, + title: label || null, + alt: label || null, + data + } + }) + .run(); + this.manager.close(); } /** - * Handles file input on the upload tab. - * Inserts a placeholder immediately (dialog closes), uploads in the background, - * then replaces the placeholder with the real image node. + * Picks a file, inserts a placeholder immediately (dialog closes), uploads in the background, + * then replaces the placeholder with the real image node (with full DotImageData). */ async onFileChange(event: Event): Promise { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; - const placeholderId = this.service.startUpload(); - if (!placeholderId) return; + const editor = this.editor(); + const pos = editor.state.selection.from; + const id = `img-upload-${Date.now()}`; + insertUploadPlaceholders(editor, pos, [{ id, mediaType: 'image' }]); + this.manager.close(); try { const { src, data } = await this.dotCmsUpload.uploadImage(file); this.zone.run(() => - this.service.finishUpload(placeholderId, { src, alt: file.name, data }) + replacePlaceholder(editor, id, { + type: DOT_IMAGE_NODE_NAME, + attrs: { src, alt: file.name, data, title: null } + }) ); } catch (err) { console.error('Image upload failed', err); - this.service.cancelUpload(placeholderId); + removePlaceholder(editor, id); } } - /** Inserts an image from the URL tab when the URL control is valid. */ onInsertUrl(): void { if (this.urlControl.invalid) return; - this.service.insert(this.urlControl.getRawValue()); + this.editor() + .chain() + .focus() + .insertContent({ + type: DOT_IMAGE_NODE_NAME, + attrs: { + src: this.urlControl.getRawValue(), + title: null, + alt: null, + data: null + } + }) + .run(); + this.manager.close(); } - /** Persists edit-mode changes (src, tooltip, alt) back into the document. */ onApplyEdit(): void { if (this.editForm.controls.src.invalid) return; const { src, title, alt } = this.editForm.getRawValue(); - this.service.insert(src, title || undefined, alt || undefined); + this.editor() + .chain() + .focus() + .updateAttributes('dotImage', { + src, + title: title || null, + alt: alt || null + }) + .run(); + this.manager.close(); } - /** - * Resets dialog UI when the panel is closed or cannot be anchored yet (`clientRectFn` missing). - * Ensures the next open does not leak tab choice, URL/search/edit values, or dotCMS list state. - */ - private resetDialogUiForClosedOrUnpositioned(): void { - this.positioned.set(false); + private resetDialogUi(): void { this.activeTab.set('url'); this.urlControl.reset(''); this.dotcmsSearchControl.reset(''); 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 deleted file mode 100644 index 1d1644c84a5b..000000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Injectable, signal } from '@angular/core'; - -import { type DotImageData } from '../../extensions/image.extension'; -import { FloatingBlockDialogService } from '../floating-block-dialog.base'; - -export type InsertImageFn = ( - src: string, - title?: string, - alt?: string, - data?: DotImageData -) => void; - -export interface ImageUploadCallbacks { - /** Called immediately when the user picks a file — inserts a placeholder and returns its id. */ - onStart: () => string; - /** Called after a successful upload — replaces the placeholder with the real image node. */ - onFinish: (id: string, attrs: { src: string; alt?: string; data?: DotImageData }) => void; - /** Called after a failed upload — removes the placeholder. */ - onCancel: (id: string) => void; -} - -export interface ImageInitialValues { - src: string; - title: string; - alt: string; -} - -export interface ImageOpenOptions { - uploadCallbacks?: ImageUploadCallbacks; - initialValues?: ImageInitialValues; -} - -@Injectable({ providedIn: 'root' }) -export class ImageDialogService extends FloatingBlockDialogService { - readonly initialValues = signal(null); - - private insertFn: InsertImageFn | null = null; - private uploadCallbacks: ImageUploadCallbacks | null = null; - - // Saved across close() so async upload lifecycle can still call finish/cancel - private pendingFinish: ImageUploadCallbacks['onFinish'] | null = null; - private pendingCancel: ImageUploadCallbacks['onCancel'] | null = null; - - open( - insertFn: InsertImageFn, - clientRectFn: () => DOMRect | null, - options?: ImageOpenOptions - ): void { - this.openFloating(clientRectFn, () => { - this.insertFn = insertFn; - this.uploadCallbacks = options?.uploadCallbacks ?? null; - this.initialValues.set(options?.initialValues ?? null); - }); - } - - insert(src: string, title?: string, alt?: string, data?: DotImageData): void { - this.insertFn?.(src, title, alt, data); - this.close(); - } - - /** - * Inserts a placeholder at the cursor position and immediately closes the dialog. - * The upload lifecycle callbacks are saved before close() clears dialog state. - * Returns the placeholder id — pass it to `finishUpload` or `cancelUpload`. - * Returns null if no upload callbacks were registered (e.g. edit mode). - */ - startUpload(): string | null { - if (!this.uploadCallbacks) return null; - // Capture before close() wipes uploadCallbacks - const { onStart, onFinish, onCancel } = this.uploadCallbacks; - this.pendingFinish = onFinish; - this.pendingCancel = onCancel; - const id = onStart(); - this.close(); - return id; - } - - finishUpload(id: string, attrs: { src: string; alt?: string; data?: DotImageData }): void { - this.pendingFinish?.(id, attrs); - this.pendingFinish = null; - this.pendingCancel = null; - } - - cancelUpload(id: string): void { - this.pendingCancel?.(id); - this.pendingFinish = null; - this.pendingCancel = null; - } - - close(): void { - this.closeFloating(() => { - this.initialValues.set(null); - this.insertFn = null; - this.uploadCallbacks = 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 index 26a3594e370e..e034afd9cc54 100644 --- 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 @@ -1,105 +1,97 @@ -import { computePosition, flip, shift } from '@floating-ui/dom'; - -import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, - NgZone, - afterRenderEffect, computed, effect, inject, - signal, + input, untracked } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { LinkDialogService } from './link-dialog.service'; +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; @Component({ - selector: 'dot-block-editor-link-dialog', + selector: 'dot-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()' - }, + imports: [ReactiveFormsModule, EditorDialogComponent], template: ` -
-

- {{ isEditing() ? 'Edit Link' : 'Insert Link' }} -

- -
- - -
- -
- - -
- - - -
- - + +
+
+

+ {{ isEditing() ? 'Edit Link' : 'Insert Link' }} +

+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+
-
+ ` }) export class LinkDialogComponent { - protected readonly service = inject(LinkDialogService); + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); 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; + protected readonly isEditing = computed( + () => this.manager.linkPayload()?.initialValues != null + ); readonly form = new FormGroup({ href: new FormControl('', { @@ -111,85 +103,94 @@ export class LinkDialogComponent { }); constructor() { - // Pre-populate form when opened in edit mode + // Pre-populate the form when opened in edit mode. effect(() => { - const values = this.service.initialValues(); + const payload = this.manager.linkPayload(); untracked(() => { + const values = payload?.initialValues; if (values) { this.form.setValue({ - href: values.href, - displayText: values.displayText, + 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; + // Reset form when dialog closes. + effect(() => { + if (!this.manager.isOpen('link')) { + untracked(() => this.form.reset({ href: '', displayText: '', openInNewTab: false })); } + }); - 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); - }); + // Manage the `link-editing` CSS class on the active link element. + effect((onCleanup) => { + if (!this.manager.isOpen('link')) return; + const linkEl = this.manager.linkPayload()?.linkEl; + if (!linkEl) return; + linkEl.classList.add('link-editing'); + onCleanup(() => linkEl.classList.remove('link-editing')); }); } + protected focusHref(): void { + const input = this.el.nativeElement.querySelector('#link-url') as HTMLElement | null; + input?.focus(); + } + onInsert(): void { if (this.form.controls.href.invalid) return; const { href, displayText, openInNewTab } = this.form.getRawValue(); - this.service.insert(href, displayText.trim() || undefined, openInNewTab); + const payload = this.manager.linkPayload(); + const editor = this.editor(); + + if (payload?.linkEl) { + // Edit mode — update the link in place using the pre-computed anchor position. + const anchorPos = + payload.anchorPos ?? + (() => { + try { + return editor.view.posAtDOM(payload.linkEl!, 0); + } catch { + return editor.state.selection.from; + } + })(); + editor + .chain() + .focus() + .setTextSelection(anchorPos) + .extendMarkRange('link') + .insertContent({ + type: 'text', + text: displayText.trim() || href, + marks: [ + { + type: 'link', + attrs: { href, target: openInNewTab ? '_blank' : null } + } + ] + }) + .run(); + } else { + // Insert mode + editor + .chain() + .focus() + .insertContent({ + type: 'text', + text: displayText.trim() || href, + marks: [ + { + type: 'link', + attrs: { href, target: openInNewTab ? '_blank' : null } + } + ] + }) + .run(); + } + + this.manager.close(); } } 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 deleted file mode 100644 index acb9b5b6377a..000000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 index 26a1ea9c4ec3..29a75cd63115 100644 --- 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 @@ -1,110 +1,102 @@ -import { computePosition, flip, shift } from '@floating-ui/dom'; - -import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, - NgZone, - afterRenderEffect, effect, inject, - signal, + input, untracked } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { TableDialogService } from './table-dialog.service'; +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; const DEFAULT_ROWS = 3; const DEFAULT_COLS = 3; const MAX_VALUE = 20; @Component({ - selector: 'dot-block-editor-table-dialog', + selector: 'dot-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()' - }, + imports: [ReactiveFormsModule, EditorDialogComponent], template: ` -
-

- Insert Table -

- -
-
- - -
-
- - -
+ +
+ +

+ Insert Table +

+ +
+
+ + +
+
+ + +
+
+ + + +
+ + +
+
- - - -
- - -
- +
` }) export class TableDialogComponent { - protected readonly service = inject(TableDialogService); + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); 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, @@ -118,74 +110,29 @@ export class TableDialogComponent { }); 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); + effect(() => { + if (!this.manager.isOpen('table')) { + untracked(() => 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); - }); }); } + protected focusFirst(): void { + const input = this.el.nativeElement.querySelector( + 'input:not([type="file"]):not([type="checkbox"])' + ) as HTMLElement | null; + input?.focus(); + } + onApply(): void { if (this.form.invalid) return; - this.service.insert(this.form.getRawValue()); + this.editor().chain().focus().insertTable(this.form.getRawValue()).run(); + this.manager.close(); } } 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 deleted file mode 100644 index a96b88bb0414..000000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 index c7954cee6d48..c4da68a3614a 100644 --- 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 @@ -1,14 +1,11 @@ -import { computePosition, flip, shift } from '@floating-ui/dom'; - -import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, NgZone, - afterRenderEffect, effect, inject, + input, signal, untracked } from '@angular/core'; @@ -18,131 +15,62 @@ import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview'; import { take } from 'rxjs/operators'; -import { VideoDialogService } from './video-dialog.service'; +import { Editor } from '@tiptap/core'; +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.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'; +import { DOT_VIDEO_NODE_NAME } from '../../extensions/video.extension'; import { EditorStore } from '../../store/editor.store'; type Tab = 'upload' | 'url' | 'dotcms'; @Component({ - selector: 'dot-block-editor-video-dialog', + selector: 'dot-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()' - }, + imports: [ReactiveFormsModule, DataViewModule, EditorDialogComponent], template: ` - -
- - - -
- - - @if (activeTab() === 'upload') { -
-
- + ` }) export class LinkDialogComponent { @@ -121,7 +121,9 @@ export class LinkDialogComponent { // Reset form when dialog closes. effect(() => { if (!this.manager.isOpen('link')) { - untracked(() => this.form.reset({ href: '', displayText: '', openInNewTab: false })); + untracked(() => + this.form.reset({ href: '', displayText: '', openInNewTab: false }) + ); } }); @@ -148,11 +150,12 @@ export class LinkDialogComponent { if (payload?.linkEl) { // Edit mode — update the link in place using the pre-computed anchor position. + const linkEl = payload.linkEl; const anchorPos = payload.anchorPos ?? (() => { try { - return editor.view.posAtDOM(payload.linkEl!, 0); + return editor.view.posAtDOM(linkEl, 0); } catch { return editor.state.selection.from; } 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 index 29a75cd63115..7ded388afd19 100644 --- 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 @@ -11,8 +11,8 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { Editor } from '@tiptap/core'; -import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; const DEFAULT_ROWS = 3; const DEFAULT_COLS = 3; @@ -23,7 +23,7 @@ const MAX_VALUE = 20; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ReactiveFormsModule, EditorDialogComponent], template: ` - +
@@ -88,7 +88,7 @@ const MAX_VALUE = 20;
- + ` }) export class TableDialogComponent { 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 index c4da68a3614a..63adf27b5733 100644 --- 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 @@ -17,16 +17,16 @@ import { take } from 'rxjs/operators'; import { Editor } from '@tiptap/core'; -import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; -import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; +import { DOT_VIDEO_NODE_NAME } from '../../extensions/video.extension'; 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'; -import { DOT_VIDEO_NODE_NAME } from '../../extensions/video.extension'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; import { EditorStore } from '../../store/editor.store'; +import { EditorDialogComponent } from '../editor-dialog/editor-dialog.component'; type Tab = 'upload' | 'url' | 'dotcms'; @@ -35,7 +35,7 @@ type Tab = 'upload' | 'url' | 'dotcms'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ReactiveFormsModule, DataViewModule, EditorDialogComponent], template: ` - +
@@ -267,7 +267,9 @@ type Tab = 'upload' | 'url' | 'dotcms'; type="button" role="option" class="flex w-full items-center gap-3 rounded px-2 py-2 text-left hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-300" - [attr.data-testid]="'dotcms-video-row-' + vid.inode" + [attr.data-testid]=" + 'dotcms-video-row-' + vid.inode + " (mousedown)=" $event.preventDefault(); insertFromDotcms(vid) @@ -293,7 +295,7 @@ type Tab = 'upload' | 'url' | 'dotcms';
}
- + ` }) export class VideoDialogComponent { 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 index e11b310ab2d8..0beeb8c9e030 100644 --- 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 @@ -19,6 +19,7 @@ import { Editor } from '@tiptap/core'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { EmojiPickerComponent } from './components/emoji-menu/emoji-picker.component'; import { ImageDialogComponent } from './components/image/image-dialog.component'; import { LinkDialogComponent } from './components/link/link-dialog.component'; import { TableDialogComponent } from './components/table/table-dialog.component'; @@ -26,7 +27,6 @@ import { VideoDialogComponent } from './components/video/video-dialog.component' import { syncCharacterStatsFromEditor } from './editor-character-stats'; import { handleEditorProseMirrorClick } from './editor-chrome-click'; import { handleMediaDrop } from './editor.utils'; -import { EmojiPickerComponent } from './components/emoji-menu/emoji-picker.component'; import { createEditorExtensions } from './extensions/editor-extensions'; import { SELECTION_PRESERVE_KEY } from './extensions/selection-preserve.extension'; import { DotCmsUploadService } from './services/dot-cms-upload.service'; 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 index baab7ec280ba..600e4876600a 100644 --- 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 @@ -6,9 +6,9 @@ import { SuggestionPluginKey } from '@tiptap/suggestion'; import { DOT_CONTENTLET_NODE_NAME } from '../extensions/contentlet.extension'; import type { BlockItem } from './slash-menu.types'; -import type { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; import type { DotCmsContentTypeService } from '../services/dot-cms-content-type.service'; import type { DotCmsContentletService } from '../services/dot-cms-contentlet.service'; +import type { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; // Narrow interface so the catalog doesn't import the full service class interface SlashMenuSubMenuHost { 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 index bb680ac2d5e2..7a9deb2622c1 100644 --- 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 @@ -8,9 +8,9 @@ import { createSlashDialogBlockItems } from './slash-menu-catalog'; -import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; import { DotCmsContentTypeService } from '../services/dot-cms-content-type.service'; import { DotCmsContentletService } from '../services/dot-cms-contentlet.service'; +import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; import { EditorStore } from '../store/editor.store'; import type { BlockItem } from './slash-menu.types'; From 714f4e31edd6ddadacb8c3a3e46059765cde45f8 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 23 Apr 2026 14:36:20 -0400 Subject: [PATCH 26/51] refactor(new-block-editor): standardize node imports and improve code organization --- .../components/image/image-dialog.component.ts | 4 ++-- .../components/video/video-dialog.component.ts | 2 +- .../src/lib/editor/editor-chrome-click.ts | 13 +++++-------- .../src/lib/editor/editor.component.ts | 2 +- .../new-block-editor/src/lib/editor/editor.utils.ts | 6 +++--- .../src/lib/editor/extensions/editor-extensions.ts | 10 +++++----- .../extensions/{ => nodes}/contentlet.extension.ts | 0 .../editor/extensions/{ => nodes}/grid.extension.ts | 2 +- .../extensions/{ => nodes}/image.extension.ts | 0 .../{ => nodes}/upload-placeholder.extension.ts | 0 .../extensions/{ => nodes}/video.extension.ts | 0 .../lib/editor/services/dot-cms-upload.service.ts | 2 +- .../src/lib/editor/slash-menu/slash-menu-catalog.ts | 2 +- .../editor/toolbar/editor-toolbar-state.service.ts | 2 +- .../src/lib/editor/toolbar/toolbar.component.ts | 6 ++++-- 15 files changed, 25 insertions(+), 26 deletions(-) rename core-web/libs/new-block-editor/src/lib/editor/extensions/{ => nodes}/contentlet.extension.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/extensions/{ => nodes}/grid.extension.ts (99%) rename core-web/libs/new-block-editor/src/lib/editor/extensions/{ => nodes}/image.extension.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/extensions/{ => nodes}/upload-placeholder.extension.ts (100%) rename core-web/libs/new-block-editor/src/lib/editor/extensions/{ => nodes}/video.extension.ts (100%) 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 index f877aeff8a79..3cd8579e8117 100644 --- 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 @@ -20,12 +20,12 @@ import { take } from 'rxjs/operators'; import { Editor } from '@tiptap/core'; -import { type DotImageData, DOT_IMAGE_NODE_NAME } from '../../extensions/image.extension'; +import { type DotImageData, DOT_IMAGE_NODE_NAME } from '../../extensions/nodes/image.extension'; import { insertUploadPlaceholders, replacePlaceholder, removePlaceholder -} from '../../extensions/upload-placeholder.extension'; +} from '../../extensions/nodes/upload-placeholder.extension'; import { DotCmsContentletService, type DotCmsContentlet 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 index 63adf27b5733..ad1eb605614e 100644 --- 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 @@ -17,7 +17,7 @@ import { take } from 'rxjs/operators'; import { Editor } from '@tiptap/core'; -import { DOT_VIDEO_NODE_NAME } from '../../extensions/video.extension'; +import { DOT_VIDEO_NODE_NAME } from '../../extensions/nodes/video.extension'; import { DotCmsContentletService, type DotCmsContentlet 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 index b5aeafb8f9d0..c59b480e9225 100644 --- 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 @@ -26,12 +26,9 @@ export function handleEditorProseMirrorClick( event.preventDefault(); - dialogManager.openLink( - () => anchor.getBoundingClientRect(), - { - initialValues: { href, displayText }, - linkEl: anchor as HTMLElement, - anchorPos - } - ); + dialogManager.openLink(() => anchor.getBoundingClientRect(), { + initialValues: { href, displayText }, + linkEl: anchor as HTMLElement, + anchorPos + }); } 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 index 0beeb8c9e030..422ccab159d5 100644 --- 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 @@ -36,7 +36,7 @@ import { SlashMenuService } from './slash-menu/slash-menu.service'; import { EditorStore } from './store/editor.store'; import { ToolbarComponent } from './toolbar/toolbar.component'; -import type { ContentletEditEvent } from './extensions/contentlet.extension'; +import type { ContentletEditEvent } from './extensions/nodes/contentlet.extension'; @Component({ selector: 'dot-block-editor', 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 index 0670d872cd18..5f470bd55a19 100644 --- 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 @@ -2,13 +2,13 @@ import { Editor } from '@tiptap/core'; import { Slice } from '@tiptap/pm/model'; import { EditorView } from '@tiptap/pm/view'; -import { DOT_IMAGE_NODE_NAME } from './extensions/image.extension'; +import { DOT_IMAGE_NODE_NAME } from './extensions/nodes/image.extension'; import { insertUploadPlaceholders, replacePlaceholder, removePlaceholder -} from './extensions/upload-placeholder.extension'; -import { DOT_VIDEO_NODE_NAME } from './extensions/video.extension'; +} from './extensions/nodes/upload-placeholder.extension'; +import { DOT_VIDEO_NODE_NAME } from './extensions/nodes/video.extension'; import { type UploadedImage } from './services/dot-cms-upload.service'; export function handleMediaDrop( 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 index e0bcba6640c3..c517c27c7bdb 100644 --- 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 @@ -10,13 +10,13 @@ import TextAlign from '@tiptap/extension-text-align'; 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 { DotContentlet } from './nodes/contentlet.extension'; +import { GridBlock, GridColumn } from './nodes/grid.extension'; +import { DotImage } from './nodes/image.extension'; +import { UploadPlaceholderExtension } from './nodes/upload-placeholder.extension'; +import { Video } from './nodes/video.extension'; import { SelectionPreserveExtension } from './selection-preserve.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'; 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/nodes/contentlet.extension.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/extensions/contentlet.extension.ts rename to core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/contentlet.extension.ts 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/nodes/grid.extension.ts similarity index 99% rename from core-web/libs/new-block-editor/src/lib/editor/extensions/grid.extension.ts rename to core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/grid.extension.ts index 7fae565f70ea..03e5ccd064e8 100644 --- 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/nodes/grid.extension.ts @@ -1,7 +1,7 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { TextSelection } from '@tiptap/pm/state'; -import { GridResizePlugin } from './grid-resize.plugin'; +import { GridResizePlugin } from '../grid-resize.plugin'; declare module '@tiptap/core' { interface Commands { 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/nodes/image.extension.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/extensions/image.extension.ts rename to core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/image.extension.ts 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/nodes/upload-placeholder.extension.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/extensions/upload-placeholder.extension.ts rename to core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/upload-placeholder.extension.ts 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/nodes/video.extension.ts similarity index 100% rename from core-web/libs/new-block-editor/src/lib/editor/extensions/video.extension.ts rename to core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/video.extension.ts 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 index 07f1766ad828..89b3fa11eace 100644 --- 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 @@ -5,7 +5,7 @@ import { map, take } from 'rxjs/operators'; import { DOT_CMS_AUTH_TOKEN, DOT_CMS_BASE_URL } from './dot-cms.config'; -import { type DotImageData } from '../extensions/image.extension'; +import { type DotImageData } from '../extensions/nodes/image.extension'; const BASE_URL = DOT_CMS_BASE_URL; const AUTH_TOKEN = DOT_CMS_AUTH_TOKEN; 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 index 600e4876600a..3a377005cb91 100644 --- 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 @@ -3,7 +3,7 @@ import { firstValueFrom } from 'rxjs'; import type { Editor } from '@tiptap/core'; import { SuggestionPluginKey } from '@tiptap/suggestion'; -import { DOT_CONTENTLET_NODE_NAME } from '../extensions/contentlet.extension'; +import { DOT_CONTENTLET_NODE_NAME } from '../extensions/nodes/contentlet.extension'; import type { BlockItem } from './slash-menu.types'; import type { DotCmsContentTypeService } from '../services/dot-cms-content-type.service'; 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 index 19c7b8e4baf0..f3f5cab8a64b 100644 --- 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 @@ -3,7 +3,7 @@ import { Injectable, NgZone, inject, signal } from '@angular/core'; import { Editor } from '@tiptap/core'; import { NodeSelection } from '@tiptap/pm/state'; -import type { ContentletEditEvent } from '../extensions/contentlet.extension'; +import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extension'; @Injectable({ providedIn: 'root' }) export class EditorToolbarStateService { 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 index ad68a61a90bc..a4df9c04eb23 100644 --- 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 @@ -18,7 +18,7 @@ import { EditorToolbarStateService } from './editor-toolbar-state.service'; import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; import { EditorStore } from '../store/editor.store'; -import type { ContentletEditEvent } from '../extensions/contentlet.extension'; +import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extension'; @Component({ selector: 'dot-toolbar', @@ -591,7 +591,9 @@ export class ToolbarComponent implements OnDestroy { const selectedText = empty ? '' : editor.state.doc.textBetween(from, to); this.dialogManager.openLink( () => btn.getBoundingClientRect(), - selectedText ? { initialValues: { href: '', displayText: selectedText } } : undefined + selectedText + ? { initialValues: { href: '', displayText: selectedText } } + : undefined ); } } From 88385c0bf80626159b7aab491f1aa1fdb8b7ab26 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 23 Apr 2026 15:33:43 -0400 Subject: [PATCH 27/51] feat(new-block-editor): enhance video handling with additional metadata and improved upload process --- .../video/video-dialog.component.ts | 21 ++++-- .../src/lib/editor/editor.component.ts | 67 +++++++++++++++++-- .../src/lib/editor/editor.utils.ts | 8 +-- .../extensions/nodes/video.extension.ts | 34 +++++++++- .../editor/services/dot-cms-upload.service.ts | 38 ++++++++--- 5 files changed, 144 insertions(+), 24 deletions(-) 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 index ad1eb605614e..adbadc62fa89 100644 --- 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 @@ -415,7 +415,20 @@ export class VideoDialogComponent { this.editor() .chain() .focus() - .insertContent({ type: DOT_VIDEO_NODE_NAME, attrs: { src, title: title ?? null } }) + .insertContent({ + type: DOT_VIDEO_NODE_NAME, + attrs: { + src, + title: title ?? null, + data: { + identifier: contentlet.identifier, + inode: contentlet.inode, + languageId: contentlet.languageId, + title: contentlet.title ?? '', + asset: `/dA/${contentlet.inode}` + } + } + }) .run(); this.manager.close(); } @@ -426,7 +439,7 @@ export class VideoDialogComponent { this.uploading.set(true); try { - const src = await this.dotCmsUpload.uploadVideo(file); + const { src, data } = await this.dotCmsUpload.uploadVideo(file); const title = file.name.replace(/\.[^.]+$/, ''); this.zone.run(() => { this.editor() @@ -434,7 +447,7 @@ export class VideoDialogComponent { .focus() .insertContent({ type: DOT_VIDEO_NODE_NAME, - attrs: { src, title: title ?? null } + attrs: { src, title: title ?? null, data } }) .run(); this.manager.close(); @@ -454,7 +467,7 @@ export class VideoDialogComponent { .focus() .insertContent({ type: DOT_VIDEO_NODE_NAME, - attrs: { src: this.urlControl.getRawValue(), title: title ?? null } + attrs: { src: this.urlControl.getRawValue(), title: title ?? null, data: null } }) .run(); this.manager.close(); 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 index 422ccab159d5..2a1c1cb7dea8 100644 --- 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 @@ -38,6 +38,14 @@ import { ToolbarComponent } from './toolbar/toolbar.component'; import type { ContentletEditEvent } from './extensions/nodes/contentlet.extension'; +/** + * DotCMS block editor shell: TipTap surface, toolbar, slash menu, floating dialogs + * (table, image, video, link, emoji), media drag-and-drop, optional fullscreen overlay, + * live document stats, and Angular {@link ControlValueAccessor} for two-way HTML binding. + * + * Registers {@link EditorStore} and {@link SlashMenuService} at component scope so each + * editor instance has isolated menu and shared UI state. + */ @Component({ selector: 'dot-block-editor', changeDetection: ChangeDetectionStrategy.OnPush, @@ -228,12 +236,25 @@ import type { ContentletEditEvent } from './extensions/nodes/contentlet.extensio ` }) export class EditorComponent implements OnDestroy, ControlValueAccessor { + /** Slash menu state; used by the template for ARIA on the ProseMirror surface. */ protected readonly menuService = inject(SlashMenuService); + + /** Field-scoped UI state (e.g. allowed blocks, language for API calls). */ protected readonly store = inject(EditorStore); + + /** Opens/closes floating dialogs and supplies payloads (e.g. link edit context). */ private readonly dialogManager = inject(EditorDialogManagerService); + + /** Uploads user-dropped image and video files to dotCMS. */ private readonly dotCmsUpload = inject(DotCmsUploadService); + + /** Document root for fullscreen scroll lock and global key listeners. */ private readonly document = inject(DOCUMENT); + /** + * TipTap node names or block identifiers allowed in this field (slash menu, toolbar). + * When omitted, {@link createEditorExtensions} uses its default set. + */ readonly allowedBlocks = input(); /** @@ -279,18 +300,32 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { * ControlValueAccessor. Angular form consumers should use ngModel or formControl instead. */ readonly valueChange = output(); + + /** + * Emits when the user chooses to edit an embedded DotCMS contentlet from the document. + */ readonly contentletEdit = output(); + /** Current word count shown in the footer stats bar. */ readonly wordCount = signal(0); + + /** Current character count shown in the footer stats bar. */ readonly charCount = signal(0); + + /** Estimated whole-minute reading time for the footer stats bar. */ readonly readingTime = signal(0); + /** Signals updated by {@link syncCharacterStatsFromEditor} on create and update. */ private readonly stats = { wordCount: this.wordCount, charCount: this.charCount, readingTime: this.readingTime }; + /** + * Shared TipTap {@link Editor} for the host template and all child editor components. + * Configured with dotCMS extensions, drop handling, and stats sync. + */ readonly editor: Editor = new Editor({ onCreate: ({ editor }) => syncCharacterStatsFromEditor(editor, this.stats), onUpdate: ({ editor }) => { @@ -316,32 +351,41 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { content: '' }); - // ── Fullscreen (F3) ────────────────────────────────────────────────────── - + /** When true, renders the editor in a modal-style fullscreen overlay. */ readonly isFullscreen = signal(false); - // ── Selection preserve ─────────────────────────────────────────────────── - + /** + * True while any managed dialog or the slash menu is open; drives selection-preservation + * meta so the user still sees what range they were editing. + */ private readonly anyDialogOpen = computed( () => this.dialogManager.activeDialog() !== null || this.menuService.isOpen() ); + /** Toggles {@link isFullscreen} from the toolbar control. */ protected toggleFullscreen(): void { this.isFullscreen.update((v) => !v); } + /** Backdrop/layout classes for the outer wrapper (fullscreen dimmer vs inline). */ protected readonly wrapperClass = computed(() => this.isFullscreen() ? 'fixed inset-0 z-[9998] flex items-center justify-center bg-black/50' : '' ); + /** Inner panel sizing and chrome classes (fullscreen vs default card layout). */ 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' ); + /** + * Subscribes inputs to {@link EditorStore} and the TipTap document, applies selection + * preservation while overlays are open, and locks document scroll + Escape-to-exit while + * {@link isFullscreen} is active. + */ constructor() { // Sync allowedBlocks input → store effect(() => { @@ -368,7 +412,7 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { ); }); - // F3: Escape key + scroll lock for fullscreen + // Fullscreen: body scroll lock + Escape closes overlay when no dialog/menu is open effect((onCleanup) => { if (!this.isFullscreen()) return; this.document.body.style.overflow = 'hidden'; @@ -388,22 +432,32 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { }); } + /** + * Delegates ProseMirror clicks to shared chrome logic (e.g. opening dialogs for nodes). + * + * @param event - Native click from the TipTap host element. + */ onClick(event: MouseEvent): void { handleEditorProseMirrorClick(event, this.editor, this.dialogManager); } + /** Restores body scroll and destroys the TipTap instance. */ ngOnDestroy(): void { this.document.body.style.overflow = ''; this.editor.destroy(); } + /** Bound in {@link registerOnChange}; forwards editor HTML to the form control. */ private onChange: (value: string) => void = (_value: string) => { // Implementation provided by registerOnChange }; + + /** Bound in {@link registerOnTouched}; marks the control touched on editor blur. */ private onTouched: () => void = () => { // Implementation provided by registerOnTouched }; + /** @inheritdoc */ writeValue(content: string | null): void { const html = content ?? ''; if (html !== this.editor.getHTML()) { @@ -411,14 +465,17 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { } } + /** @inheritdoc */ registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } + /** @inheritdoc */ registerOnTouched(fn: () => void): void { this.onTouched = fn; } + /** @inheritdoc */ 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 index 5f470bd55a19..861ecd6e503a 100644 --- 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 @@ -9,7 +9,7 @@ import { removePlaceholder } from './extensions/nodes/upload-placeholder.extension'; import { DOT_VIDEO_NODE_NAME } from './extensions/nodes/video.extension'; -import { type UploadedImage } from './services/dot-cms-upload.service'; +import { type UploadedImage, type UploadedVideo } from './services/dot-cms-upload.service'; export function handleMediaDrop( editor: Editor, @@ -18,7 +18,7 @@ export function handleMediaDrop( _slice: Slice, moved: boolean, uploadImage?: (file: File) => Promise, - uploadVideo?: (file: File) => Promise + uploadVideo?: (file: File) => Promise ): boolean { if (moved) return false; @@ -79,11 +79,11 @@ export function handleMediaDrop( if (uploadVideo) { uploadVideo(file) - .then((src) => { + .then(({ src, data }) => { const title = file.name.replace(/\.[^.]+$/, ''); replacePlaceholder(editor, id, { type: DOT_VIDEO_NODE_NAME, - attrs: { src, title } + attrs: { src, title, data } }); }) .catch((err) => { diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/video.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/video.extension.ts index b87372715cad..9484056328a1 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/video.extension.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/video.extension.ts @@ -3,6 +3,14 @@ import { Node, mergeAttributes } from '@tiptap/core'; /** TipTap node name for embedded dotCMS videos (slash menu → video). */ export const DOT_VIDEO_NODE_NAME = 'dotVideo' as const; +export interface DotVideoData { + identifier: string; + inode: string; + languageId: number; + title: string; + asset: string; +} + export const Video = Node.create({ name: DOT_VIDEO_NODE_NAME, group: 'block', @@ -11,7 +19,23 @@ export const Video = Node.create({ addAttributes() { return { src: { default: null }, - title: { default: null } + title: { default: null }, + data: { + default: null, + parseHTML: (el) => { + const raw = el.getAttribute('data'); + if (!raw) return null; + try { + return JSON.parse(raw) as DotVideoData; + } catch { + return null; + } + }, + renderHTML: ({ data }: { data: DotVideoData | null }) => { + if (!data) return {}; + return { data: JSON.stringify(data) }; + } + } }; }, @@ -32,8 +56,12 @@ export const Video = Node.create({ dom.setAttribute('controls', ''); dom.classList.add('w-full', 'rounded'); - if (node.attrs.src) { - dom.setAttribute('src', String(node.attrs.src)); + const resolvedSrc = + (node.attrs.src as string | null) ?? + (node.attrs.data as DotVideoData | null)?.asset ?? + null; + if (resolvedSrc) { + dom.setAttribute('src', resolvedSrc); } if (node.attrs.title) { 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 index 89b3fa11eace..6d5fa657b996 100644 --- 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 @@ -6,6 +6,7 @@ import { map, take } from 'rxjs/operators'; import { DOT_CMS_AUTH_TOKEN, DOT_CMS_BASE_URL } from './dot-cms.config'; import { type DotImageData } from '../extensions/nodes/image.extension'; +import { type DotVideoData } from '../extensions/nodes/video.extension'; const BASE_URL = DOT_CMS_BASE_URL; const AUTH_TOKEN = DOT_CMS_AUTH_TOKEN; @@ -15,6 +16,11 @@ export interface UploadedImage { data: DotImageData; } +export interface UploadedVideo { + src: string; + data: DotVideoData; +} + @Injectable({ providedIn: 'root' }) export class DotCmsUploadService { private readonly http = inject(HttpClient); @@ -29,7 +35,7 @@ export class DotCmsUploadService { return this.uploadAsset(file); } - async uploadVideo(file: File): Promise { + async uploadVideo(file: File): Promise { return this.uploadVideoAsset(file); } @@ -45,16 +51,16 @@ export class DotCmsUploadService { return result; } - private async uploadVideoAsset(file: File): Promise { + private async uploadVideoAsset(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.publishVideoAsset(tempId).pipe(take(1)).toPromise(); - if (url === undefined) { + const result = await this.publishVideoAsset(tempId).pipe(take(1)).toPromise(); + if (result === undefined) { throw new Error('Publish: no value emitted'); } - return url; + return result; } private uploadToTemp(file: File) { @@ -127,8 +133,15 @@ export class DotCmsUploadService { } private publishVideoAsset(tempId: string) { + interface PublishContentlet { + asset: string; + identifier: string; + inode: string; + languageId: number; + title: string; + } interface PublishBody { - entity: { results: Array> }; + entity: { results: Array> }; } return this.http @@ -155,9 +168,18 @@ export class DotCmsUploadService { 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; + const contentlet = Object.values(row)[0] as PublishContentlet | undefined; if (!contentlet?.asset) throw new Error('Publish: missing asset path'); - return `${BASE_URL}${contentlet.asset}`; + return { + src: `${BASE_URL}${contentlet.asset}`, + data: { + identifier: contentlet.identifier, + inode: contentlet.inode, + languageId: contentlet.languageId, + title: contentlet.title ?? '', + asset: contentlet.asset + } satisfies DotVideoData + }; }) ); } From 19a28e543a1f1f638a74777816d7e01ae74631a7 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Thu, 23 Apr 2026 16:49:27 -0400 Subject: [PATCH 28/51] refactor(new-block-editor): update component imports and enhance styling - Replaced `EditorComponent` with `DotCMSEditorComponent` across various files for consistency. - Removed unused global styles from `styles.css` and added new styles to `editor.component.css`. - Introduced a new block options constant for better management of allowed blocks in the editor settings. - Updated toolbar icons to use the correct Material Icons set. - Improved the handling of editor content normalization and state management. This refactor aims to streamline the block editor's structure and improve maintainability. --- .../src/app/app.component.ts | 4 +- .../apps/dotcms-block-editor/src/styles.css | 215 ----------- .../dot-block-editor-settings.component.ts | 25 +- core-web/apps/dotcms-ui/src/style.css | 1 + ...t-edit-content-block-editor.component.html | 2 +- ...dot-edit-content-block-editor.component.ts | 4 +- core-web/libs/new-block-editor/src/lib/app.ts | 2 +- .../src/lib/editor/editor.component.css | 341 ++++++++++++++++++ .../src/lib/editor/editor.component.ts | 160 ++------ .../services/editor-dialog-manager.service.ts | 2 + .../lib/editor/toolbar/toolbar.component.ts | 64 ++-- .../dot-block-editor-sidebar.component.ts | 4 +- ...-content-compare-block-editor.component.ts | 13 +- 13 files changed, 444 insertions(+), 393 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/editor.component.css 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 01628a04222b..280c0c7a9440 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,13 +1,13 @@ import { Component } from '@angular/core'; -import { EditorComponent } from '@dotcms/new-block-editor'; +import { DotCMSEditorComponent } from '@dotcms/new-block-editor'; import { EDITOR_DEMO_CONTENT } from './editor-demo-content'; @Component({ selector: 'dotcms-root', templateUrl: './app.component.html', styleUrls: [], - imports: [EditorComponent], + imports: [DotCMSEditorComponent], standalone: true }) export class AppComponent { diff --git a/core-web/apps/dotcms-block-editor/src/styles.css b/core-web/apps/dotcms-block-editor/src/styles.css index 67217599ead9..7f3104b3d205 100644 --- a/core-web/apps/dotcms-block-editor/src/styles.css +++ b/core-web/apps/dotcms-block-editor/src/styles.css @@ -1,218 +1,3 @@ -/* You can add global styles to this file, and also import other style files */ - @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; -} - -@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-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts index 47d7973a6126..d6b1069c110c 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-edit/components/dot-block-editor-settings/dot-block-editor-settings.component.ts @@ -16,13 +16,32 @@ import { FormBuilder, FormGroup } from '@angular/forms'; import { catchError, take, takeUntil, tap } from 'rxjs/operators'; -// Services -import { getEditorBlockOptions } from '@dotcms/block-editor'; import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; import { DotCMSContentTypeField, DotDialogActions, DotFieldVariable } from '@dotcms/dotcms-models'; import { DotFieldVariablesService } from '../fields/dot-content-type-fields-variables/services/dot-field-variables.service'; +const BLOCK_OPTIONS: { label: string; code: string }[] = [ + { label: 'AI Content', code: 'aiContentPrompt' }, + { label: 'AI Image', code: 'aiImagePrompt' }, + { label: 'Blockquote', code: 'blockquote' }, + { label: 'Code Block', code: 'codeBlock' }, + { label: 'Contentlet', code: 'dotContent' }, + { label: 'Grid (2 columns)', code: 'gridBlock' }, + { label: 'Heading 1', code: 'heading1' }, + { label: 'Heading 2', code: 'heading2' }, + { label: 'Heading 3', code: 'heading3' }, + { label: 'Heading 4', code: 'heading4' }, + { label: 'Heading 5', code: 'heading5' }, + { label: 'Heading 6', code: 'heading6' }, + { label: 'Horizontal Line', code: 'horizontalRule' }, + { label: 'Image', code: 'image' }, + { label: 'List Ordered', code: 'orderedList' }, + { label: 'List Unordered', code: 'bulletList' }, + { label: 'Table', code: 'table' }, + { label: 'Video', code: 'video' } +]; + /* Uncomment this when Content Assets variable is ready const BLOCK_EDITOR_ASSETS = [ { label: 'Youtube Videos', code: 'videos'}, @@ -54,7 +73,7 @@ export class DotBlockEditorSettingsComponent implements OnInit, OnDestroy, OnCha allowedBlocks: { label: 'Allowed Blocks', placeholder: 'Select Blocks', - options: getEditorBlockOptions(), + options: BLOCK_OPTIONS, key: 'allowedBlocks', variable: null } diff --git a/core-web/apps/dotcms-ui/src/style.css b/core-web/apps/dotcms-ui/src/style.css index e9dfe1aa8a02..95f3cb10f8ed 100644 --- a/core-web/apps/dotcms-ui/src/style.css +++ b/core-web/apps/dotcms-ui/src/style.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import 'tailwindcss-primeui'; +@plugin "@tailwindcss/typography"; @import './daisyui-theme.css'; :root { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html index 585cbc3d946b..a95fcd9964ea 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html @@ -16,7 +16,7 @@ [languageId]="$languageId()" [formControlName]="field.variable" [contentlet]="$contentlet()" - [hasFieldError]="fieldHasError" + [hasError]="fieldHasError" [field]="field" /> diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.ts index f0df8157a58c..f4ced1b994d5 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; -import { BlockEditorModule } from '@dotcms/block-editor'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotCMSEditorComponent } from '@dotcms/new-block-editor'; import { DotEditContentStore } from '../../store/edit-content.store'; import { DotCardFieldContentComponent } from '../dot-card-field/components/dot-card-field-content.component'; @@ -18,7 +18,7 @@ import { BaseWrapperField } from '../shared/base-wrapper-field'; DotCardFieldContentComponent, DotCardFieldLabelComponent, - BlockEditorModule + DotCMSEditorComponent ], templateUrl: './dot-edit-content-block-editor.component.html', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/core-web/libs/new-block-editor/src/lib/app.ts b/core-web/libs/new-block-editor/src/lib/app.ts index 51b15a5a65ad..b96b610ce010 100644 --- a/core-web/libs/new-block-editor/src/lib/app.ts +++ b/core-web/libs/new-block-editor/src/lib/app.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { EditorComponent } from './editor/editor.component'; +import { DotCMSEditorComponent as EditorComponent } from './editor/editor.component'; @Component({ selector: 'dot-block-editor-root', diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.css b/core-web/libs/new-block-editor/src/lib/editor/editor.component.css new file mode 100644 index 000000000000..edb5a90957a3 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.css @@ -0,0 +1,341 @@ +/* ─── ProseMirror base ──────────────────────────────────── */ +:host ::ng-deep .ProseMirror { + outline: none; + min-height: 200px; +} + +/* ─── Editor padding ────────────────────────────────────── */ +:host ::ng-deep .tiptap { + padding: 2rem 4rem; +} + +/* ─── Material Symbols (Outlined) ──────────────────────── */ +/* Font must be loaded by the consuming app (Google Fonts or self-hosted) */ +:host ::ng-deep .material-symbols-outlined { + font-family: 'Material Icons'; + 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; +} + +/* ─── Figures / Images ──────────────────────────────────── */ +: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-content'].is-selected { + outline: 2px solid #6366f1; + outline-offset: 2px; + border-radius: 2px; +} + +/* ─── Drag handle ──────────────────────────────────────── */ +/* The extension manages show/hide via element.style.visibility — do NOT use opacity here */ +:host ::ng-deep .drag-handle-wrapper { + display: flex; + align-items: center; + gap: 2px; + z-index: 20; +} + +:host ::ng-deep .drag-handle, +:host ::ng-deep .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; +} + +:host ::ng-deep .drag-handle { + cursor: grab; +} + +:host ::ng-deep .add-block-btn { + cursor: pointer; + border: none; + background: transparent; + padding: 0; +} + +:host ::ng-deep .drag-handle:hover, +:host ::ng-deep .add-block-btn:hover { + color: #374151; + background-color: #f3f4f6; +} + +:host ::ng-deep .drag-handle:active { + cursor: grabbing; + background-color: #e5e7eb; +} + +:host ::ng-deep .add-block-btn:active { + background-color: #e5e7eb; +} + +/* ─── Table ─────────────────────────────────────────────── */ +:host ::ng-deep .tiptap table { + border-collapse: collapse; + width: 100%; + table-layout: fixed; + overflow: hidden; + margin: 0; +} + +:host ::ng-deep .tiptap td, +:host ::ng-deep .tiptap th { + border: 1px solid #e5e7eb; + padding: 8px 12px; + vertical-align: top; + position: relative; + min-width: 80px; +} + +:host ::ng-deep .tiptap th { + background-color: #f9fafb; + font-weight: 600; + text-align: left; +} + +:host ::ng-deep .tiptap .selectedCell { + background-color: #eff6ff; +} + +:host ::ng-deep .tiptap .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + background-color: #6366f1; + cursor: col-resize; + pointer-events: all; +} + +:host ::ng-deep .tiptap .tableWrapper { + overflow-x: auto; +} + +/* ─── Placeholders ──────────────────────────────────────── */ +:host ::ng-deep .tiptap p.is-empty::before, +:host ::ng-deep .tiptap h1.is-empty::before, +:host ::ng-deep .tiptap h2.is-empty::before, +:host ::ng-deep .tiptap h3.is-empty::before, +:host ::ng-deep .tiptap h4.is-empty::before, +:host ::ng-deep .tiptap blockquote p.is-empty::before { + content: attr(data-placeholder); + color: #9ca3af; + pointer-events: none; + float: left; + height: 0; +} + +/* ─── Links ─────────────────────────────────────────────── */ +:host ::ng-deep .tiptap a { + color: #6366f1; + text-decoration: underline; + cursor: pointer; +} + +:host ::ng-deep .tiptap a:hover { + color: #4f46e5; +} + +:host ::ng-deep .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 ─────────────────────────────── */ +:host ::ng-deep .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; +} + +:host ::ng-deep .upload-placeholder__icon { + font-size: 1.25rem; + color: #9ca3af; + flex-shrink: 0; +} + +:host ::ng-deep .upload-placeholder__label { + font-size: 0.875rem; + flex: 1; +} + +:host ::ng-deep .upload-placeholder__bar { + width: 100px; + height: 4px; + border-radius: 9999px; + background-color: #e5e7eb; + overflow: hidden; + flex-shrink: 0; + position: relative; +} + +:host ::ng-deep .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; +} + +@keyframes upload-sweep { + 0% { + transform: translateX(-100%) scaleX(0.4); + } + 50% { + transform: translateX(60%) scaleX(0.6); + } + 100% { + transform: translateX(200%) scaleX(0.4); + } +} + +/* ─── 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); +} + +/* ─── Grid 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; +} + +/* ─── Grid drag preview ─────────────────────────────────── */ +: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); +} + +/* ─── Selection preserved while a dialog is open ────────── */ +:host ::ng-deep .ProseMirror .editor-selection-preserved { + background-color: rgba(59, 130, 246, 0.2); + border-radius: 4px; + transition: background-color 0.15s ease; +} + +:host ::ng-deep .ProseMirror .editor-selection-preserved::before { + display: none !important; +} 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 index 2a1c1cb7dea8..51f1cc6aa95b 100644 --- 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 @@ -15,7 +15,7 @@ import { } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { Editor } from '@tiptap/core'; +import { Editor, type JSONContent } from '@tiptap/core'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; @@ -38,6 +38,27 @@ import { ToolbarComponent } from './toolbar/toolbar.component'; import type { ContentletEditEvent } from './extensions/nodes/contentlet.extension'; +/** + * Normalizes incoming editor content to either an HTML string or a JSONContent object. + * dotCMS stores block editor fields as ProseMirror JSON (object or stringified). + * TipTap's setContent accepts both formats natively. + */ +function normalizeEditorContent( + content: string | JSONContent | null | undefined +): string | JSONContent { + if (!content) return ''; + if (typeof content !== 'string') return content; + const trimmed = content.trimStart(); + if (trimmed.startsWith('{')) { + try { + return JSON.parse(content) as JSONContent; + } catch { + // fall through to HTML + } + } + return content; +} + /** * DotCMS block editor shell: TipTap surface, toolbar, slash menu, floating dialogs * (table, image, video, link, emoji), media drag-and-drop, optional fullscreen overlay, @@ -49,12 +70,13 @@ import type { ContentletEditEvent } from './extensions/nodes/contentlet.extensio @Component({ selector: 'dot-block-editor', changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./editor.component.css'], providers: [ EditorStore, SlashMenuService, { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EditorComponent), + useExisting: forwardRef(() => DotCMSEditorComponent), multi: true } ], @@ -116,126 +138,9 @@ import type { ContentletEditEvent } from './extensions/nodes/contentlet.extensio
- `, - 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-content'].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); - } - - /* Selection preserved while a dialog is open */ - :host ::ng-deep .ProseMirror .editor-selection-preserved { - background-color: rgba(59, 130, 246, 0.2); - border-radius: 4px; - transition: background-color 0.15s ease; - } - :host ::ng-deep .ProseMirror .editor-selection-preserved::before { - display: none !important; - } ` }) -export class EditorComponent implements OnDestroy, ControlValueAccessor { +export class DotCMSEditorComponent implements OnDestroy, ControlValueAccessor { /** Slash menu state; used by the template for ARIA on the ProseMirror surface. */ protected readonly menuService = inject(SlashMenuService); @@ -378,7 +283,7 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { 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' + : 'relative rounded-lg border border-gray-200' ); /** @@ -398,10 +303,12 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { this.store.setLanguageId(id); }); - // Sync value input → editor (for web component / non-CVA usage) + // Sync value input → editor (for web component / non-CVA usage). + // Guard: skip when value is empty to avoid overriding CVA-set content on init. effect(() => { const v = this.value(); - this.editor.commands.setContent(v, { emitUpdate: false }); + if (!v) return; + this.editor.commands.setContent(normalizeEditorContent(v), { emitUpdate: false }); }); // Preserve selection highlight while any dialog is open @@ -459,10 +366,9 @@ export class EditorComponent implements OnDestroy, ControlValueAccessor { /** @inheritdoc */ writeValue(content: string | null): void { - const html = content ?? ''; - if (html !== this.editor.getHTML()) { - this.editor.commands.setContent(html, { emitUpdate: false }); - } + const parsed = normalizeEditorContent(content); + if (typeof parsed === 'string' && parsed === this.editor.getHTML()) return; + this.editor.commands.setContent(parsed, { emitUpdate: false }); } /** @inheritdoc */ diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/editor-dialog-manager.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/editor-dialog-manager.service.ts index afbaed55ac56..5fb9c113f582 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/services/editor-dialog-manager.service.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/services/editor-dialog-manager.service.ts @@ -36,6 +36,8 @@ export class EditorDialogManagerService { this.zone.run(() => this.activeDialog.set({ id, clientRectFn })); } + // TODO: Make the payload part of the open method and make the dialog component have a beforeShow that will be a callback that will receive the payload + // That way we avoid creating custom function for complicated dialogs like the image and link openImage(clientRectFn: () => DOMRect | null, payload?: ImageDialogPayload): void { this.zone.run(() => { this.imagePayload.set(payload ?? null); 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 index a4df9c04eb23..9f20e11711bf 100644 --- 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 @@ -42,7 +42,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (click)="undo()"> - + @@ -84,7 +84,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.isBold())" (click)="toggleBold()"> - + @@ -148,7 +148,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.textAlign() === 'left')" (click)="setTextAlign('left')"> - + @if (showBlockFormatsGroup()) { @@ -243,9 +243,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.isBulletList())" (click)="toggleBulletList()"> - + } @if (isAllowed('orderedList')) { @@ -257,9 +255,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.isOrderedList())" (click)="toggleOrderedList()"> - + } @if (isAllowed('blockquote')) { @@ -271,7 +267,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.isBlockquote())" (click)="toggleBlockquote()"> - + } @if (isAllowed('codeBlock')) { @@ -283,7 +279,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.isCodeBlock())" (click)="toggleCodeBlock()"> - + } } @@ -300,7 +296,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (click)="outdent()"> - + @@ -334,7 +330,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (click)="insertHR()"> - + } @@ -350,7 +346,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(state.isLink())" (mousedown)="openLinkDialog($event)"> - + } @if (isAllowed('image')) { @@ -361,7 +357,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (mousedown)="openImageDialog($event)"> - + } @if (isAllowed('video')) { @@ -372,7 +368,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (mousedown)="openVideoDialog($event)"> - + } @if (isAllowed('table')) { @@ -383,7 +379,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (mousedown)="openTableDialog($event)"> - + } @if (isAllowed('emoji')) { @@ -394,7 +390,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi tooltipPosition="bottom" [class]="btnClass(false)" (mousedown)="openEmojiPicker($event)"> - + } } @@ -409,7 +405,7 @@ import type { ContentletEditEvent } from '../extensions/nodes/contentlet.extensi [class]="btnClass(isFullscreen())" data-testid="toolbar-fullscreen" (click)="fullscreenToggle.emit()"> -