diff --git a/core-web/.claude/settings.json b/core-web/.claude/settings.json index a2fdc2bd4e9e..07cd2a069741 100644 --- a/core-web/.claude/settings.json +++ b/core-web/.claude/settings.json @@ -7,6 +7,9 @@ } } }, + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, "enabledPlugins": { "nx@nx-claude-plugins": true } diff --git a/core-web/apps/dotcms-block-editor/project.json b/core-web/apps/dotcms-block-editor/project.json index 2ac11fb1a1ed..7d197cac2f7a 100644 --- a/core-web/apps/dotcms-block-editor/project.json +++ b/core-web/apps/dotcms-block-editor/project.json @@ -7,13 +7,19 @@ "tags": ["skip:test", "skip:lint"], "targets": { "build": { - "executor": "@angular-devkit/build-angular:browser-esbuild", + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], "options": { - "outputPath": "dist/apps/dotcms-block-editor", + "baseHref": "./", + "outputPath": { + "base": "dist/apps/dotcms-block-editor", + "browser": "" + }, "index": "apps/dotcms-block-editor/src/index.html", - "main": "apps/dotcms-block-editor/src/main.ts", - "polyfills": "apps/dotcms-block-editor/src/polyfills.ts", + "browser": "apps/dotcms-block-editor/src/main.ts", + "polyfills": ["apps/dotcms-block-editor/src/polyfills.ts"], "tsConfig": "apps/dotcms-block-editor/tsconfig.app.json", + "inlineStyleLanguage": "css", "assets": [ "apps/dotcms-block-editor/src/favicon.ico", "apps/dotcms-block-editor/src/assets" @@ -33,24 +39,29 @@ "includePaths": ["libs/dotcms-scss/angular"] }, "allowedCommonJsDependencies": ["lodash.isequal", "date-fns"], - "vendorChunk": true, "extractLicenses": false, - "buildOptimizer": false, "sourceMap": true, "optimization": false, "namedChunks": true }, "configurations": { + "development": { + "optimization": false, + "sourceMap": true, + "namedChunks": true, + "extractLicenses": false + }, "localhost": { "sourceMap": true, - "optimization": false, - "watch": true + "optimization": false }, "tomcat": { - "outputPath": "../../tomcat9/webapps/ROOT/dotcms-block-editor", + "outputPath": { + "base": "../../tomcat9/webapps/ROOT/dotcms-block-editor", + "browser": "" + }, "sourceMap": true, - "optimization": false, - "watch": true + "optimization": false }, "production": { "fileReplacements": [ @@ -64,8 +75,6 @@ "sourceMap": false, "namedChunks": false, "extractLicenses": false, - "vendorChunk": false, - "buildOptimizer": true, "budgets": [ { "type": "initial", @@ -85,7 +94,7 @@ "serve": { "executor": "@angular/build:dev-server", "options": { - "buildTarget": "dotcms-block-editor:build" + "buildTarget": "dotcms-block-editor:build:development" }, "configurations": { "production": { @@ -113,7 +122,7 @@ "main": "apps/dotcms-block-editor/src/test.ts", "tsConfig": "apps/dotcms-block-editor/tsconfig.spec.json", "karmaConfig": "apps/dotcms-block-editor/karma.conf.js", - "polyfills": "apps/dotcms-block-editor/src/polyfills.ts", + "polyfills": ["apps/dotcms-block-editor/src/polyfills.ts"], "styles": [], "scripts": [], "assets": [] diff --git a/core-web/apps/dotcms-block-editor/src/app/app.component.html b/core-web/apps/dotcms-block-editor/src/app/app.component.html deleted file mode 100644 index c4f5e7a4eaf8..000000000000 --- a/core-web/apps/dotcms-block-editor/src/app/app.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/core-web/apps/dotcms-block-editor/src/app/app.component.spec.ts b/core-web/apps/dotcms-block-editor/src/app/app.component.spec.ts deleted file mode 100644 index e69de29bb2d1..000000000000 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 deleted file mode 100644 index 80797f2be3cd..000000000000 --- a/core-web/apps/dotcms-block-editor/src/app/app.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'dotcms-root', - templateUrl: './app.component.html', - styleUrls: [], - standalone: false -}) -export class AppComponent { - title = 'dotcms-block-editor'; -} 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..8427ee94efec --- /dev/null +++ b/core-web/apps/dotcms-block-editor/src/app/app.config.ts @@ -0,0 +1,27 @@ +import Lara from '@primeuix/themes/lara'; + +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + +import { providePrimeNG } from 'primeng/config'; + +/** + * PrimeNG is required for components used inside `@dotcms/new-block-editor` (e.g. DataView in + * image/video dotCMS pickers). Theme + cssLayer order must match `apps/dotcms-block-editor/src/styles.css`. + */ +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideHttpClient(), + provideAnimationsAsync(), + providePrimeNG({ + theme: { + preset: Lara, + options: { + darkModeSelector: '.dark' + } + } + }) + ] +}; 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/app/editor-demo-content.ts b/core-web/apps/dotcms-block-editor/src/app/editor-demo-content.ts new file mode 100644 index 000000000000..395f571c43f6 --- /dev/null +++ b/core-web/apps/dotcms-block-editor/src/app/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

+ +
    +
  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/apps/dotcms-block-editor/src/index.html b/core-web/apps/dotcms-block-editor/src/index.html index dce10144bf60..d6a9ba0ecbef 100644 --- a/core-web/apps/dotcms-block-editor/src/index.html +++ b/core-web/apps/dotcms-block-editor/src/index.html @@ -3,11 +3,16 @@ 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..7960449533b1 100644 --- a/core-web/apps/dotcms-block-editor/src/main.ts +++ b/core-web/apps/dotcms-block-editor/src/main.ts @@ -1,13 +1,44 @@ +import 'zone.js'; import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { createApplication } from '@angular/platform-browser'; +import { createCustomElement } from '@angular/elements'; -import { AppModule } from './app/app.module'; +import { DotCMSEditorComponent } from '@dotcms/new-block-editor'; + +import { appConfig } from './app/app.config'; +import { EDITOR_DEMO_CONTENT } from './app/editor-demo-content'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } -platformBrowserDynamic() - .bootstrapModule(AppModule) +createApplication(appConfig) + .then((appRef) => { + if (!customElements.get('dotcms-block-editor')) { + const element = createCustomElement(DotCMSEditorComponent, { + injector: appRef.injector + }); + customElements.define('dotcms-block-editor', element); + } + + if (!environment.production) { + wireDevDemo(); + } + }) .catch((err) => console.error(err)); + +/** + * Local dev only: when `index.html` includes ``, prefill it with + * sample content so `nx serve dotcms-block-editor` shows a working editor. The JSP host page + * never renders an element with `id="demo"`, so this is a no-op in production. + */ +function wireDevDemo(): void { + const demo = document.getElementById('demo') as (HTMLElement & { value?: string }) | null; + if (!demo) return; + demo.value = EDITOR_DEMO_CONTENT; + demo.addEventListener('valueChange', (event) => { + // eslint-disable-next-line no-console + console.debug('[dev demo] valueChange', (event as CustomEvent).detail); + }); +} diff --git a/core-web/apps/dotcms-block-editor/src/styles.css b/core-web/apps/dotcms-block-editor/src/styles.css index 19318a93cbfd..7f3104b3d205 100644 --- a/core-web/apps/dotcms-block-editor/src/styles.css +++ b/core-web/apps/dotcms-block-editor/src/styles.css @@ -1,7 +1,3 @@ @import 'tailwindcss'; @import 'tailwindcss-primeui'; - -.p-dialog-mask.p-component-overlay.p-dialog-mask-scrollblocker { - background-color: transparent; - backdrop-filter: none; -} +@plugin "@tailwindcss/typography"; 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/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/assets/MaterialSymbolsOutlined.woff2 b/core-web/apps/dotcms-ui/src/assets/MaterialSymbolsOutlined.woff2 new file mode 100644 index 000000000000..e137e1d8ed3b Binary files /dev/null and b/core-web/apps/dotcms-ui/src/assets/MaterialSymbolsOutlined.woff2 differ diff --git a/core-web/apps/dotcms-ui/src/style.css b/core-web/apps/dotcms-ui/src/style.css index 0fddf4354a58..fb098abdc121 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/dotcms-scss/angular/styles.scss b/core-web/libs/dotcms-scss/angular/styles.scss index 1eb9261796fa..86cd29d4ef8d 100644 --- a/core-web/libs/dotcms-scss/angular/styles.scss +++ b/core-web/libs/dotcms-scss/angular/styles.scss @@ -106,14 +106,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/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/.eslintrc.json b/core-web/libs/new-block-editor/.eslintrc.json new file mode 100644 index 000000000000..66e6eb7f6d0c --- /dev/null +++ b/core-web/libs/new-block-editor/.eslintrc.json @@ -0,0 +1,38 @@ +{ + "extends": ["../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "dot", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "dot", + "style": "kebab-case" + } + ], + "@angular-eslint/prefer-standalone": "off", + "@angular-eslint/no-input-rename": "off" + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md new file mode 100644 index 000000000000..4eb7b394296c --- /dev/null +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -0,0 +1,95 @@ +## 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** + +## 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/nodes/image.extension.ts` | +| Video | `dotVideo` | `extensions/nodes/video.extension.ts` | +| Contentlet | `dotContent` | `extensions/nodes/contentlet/contentlet.extension.ts` | +| Grid block | `gridBlock` | `extensions/nodes/grid.extension.ts` | +| Grid column | `gridColumn` | `extensions/nodes/grid.extension.ts` | +| AI content | `aiContent` | `extensions/nodes/ai-content.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. + +--- + +## Dialog System Architecture + +### Pick the right dialog primitive + +| Content size | Use | Anchored to | Examples | +|--------------|-----|-------------|----------| +| Compact (single form, no preview) | `` shell | Caret position via `@floating-ui/dom` | image, video, link, table | +| Large (textarea + preview, multi-pane, scrollable list) | PrimeNG `` (centered modal) | Viewport center | AI content | + +When a dialog has both an input area AND a result/preview area, default to the centered modal — caret-anchored shells get cramped. + +### Caret-anchored shell (``) + +All compact dialogs (table, image, video, link, emoji) share a single `EditorDialogManagerService` and an `` shell component: + +- `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.component.ts`) — shell wrapper: absolute positioning via `@floating-ui/dom`, `display:none` toggle, Escape + click-outside dismiss, `` projection, `(opened)` output for auto-focus. + +Each compact 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. + +### Centered modal (PrimeNG ``) + +Large dialogs use PrimeNG directly — no shell. State lives outside `EditorDialogManagerService.activeDialog` (which assumes a caret rect) on dedicated signals: + +- `aiContentOpen` signal + `openAiContent()` / `closeAiContent()` methods on the manager. +- The dialog binds `[visible]="manager.aiContentOpen()"` and emits `(visibleChange)` to propagate Escape / X clicks back to the manager. +- Auto-focus happens inside the dialog component on the textarea — PrimeNG handles modal scroll-lock and overlay rendering. \ No newline at end of file diff --git a/core-web/libs/new-block-editor/PORTING_CHECKLIST.md b/core-web/libs/new-block-editor/PORTING_CHECKLIST.md new file mode 100644 index 000000000000..a5e14b9252a1 --- /dev/null +++ b/core-web/libs/new-block-editor/PORTING_CHECKLIST.md @@ -0,0 +1,114 @@ +# new-block-editor — Porting Checklist + +Tracks everything still missing from `new-block-editor` compared to the legacy `block-editor` lib. +Check an item off when the equivalent has been implemented (does not need to be a 1:1 copy — modern patterns are expected). + +--- + +## Already Ported + +- [x] Image extension (with text-wrap support) + image dialog (Upload / URL / dotCMS tabs) +- [x] Video extension + video dialog (Upload / URL / dotCMS tabs) +- [x] Table creation dialog (dimensions + header row toggle) + TableKit extension +- [x] Link dialog +- [x] GridBlock + GridColumn extensions +- [x] Grid column resize plugin +- [x] Contentlet node (`dotContentlet`) +- [x] Upload placeholder node (icon + label + animated progress bar during uploads) +- [x] Slash command extension + slash menu component + slash menu service +- [x] Block gutter extension (drag handle + add block button) +- [x] Emoji picker component + service +- [x] Toolbar component + toolbar state service (covers bold, italic, strikethrough, code, link, image wrap, image properties — no bubble menu needed) +- [x] Character count, word count, and reading time (shown in editor footer via `editor-character-stats.ts`) +- [x] DotCMS upload service +- [x] DotCMS content type service +- [x] DotCMS contentlet service (image + video search with pagination) + +--- + +## Still To Port + +### Nodes + +- [ ] **`aiContent`** — block node for AI-generated text with loading state support + > `block-editor/src/lib/nodes/ai-content/ai-content.node.ts` + +- [ ] **`loader`** — generic spinner node for in-progress async editor operations (not upload-specific; used e.g. while waiting for AI responses) + > `block-editor/src/lib/nodes/loader/loader.node.ts` + +--- + +### Extensions + +- [ ] **`aiContentPrompt`** — extension + component + store that opens a prompt modal for AI text generation + > `block-editor/src/lib/extensions/ai-content-prompt/` + +- [ ] **`aiImagePrompt`** — extension that opens a dialog for AI image generation + > `block-editor/src/lib/extensions/ai-image-prompt/ai-image-prompt.extension.ts` + +- [ ] **`indent`** — indent/outdent commands with configurable min/max levels for block nodes + > `block-editor/src/lib/extensions/indent/indent.extension.ts` + +- [ ] **`freezeScroll`** — toggleable extension that prevents editor scroll during modal interactions + > `block-editor/src/lib/extensions/freeze-scroll/freeze-scroll.extension.ts` + +- [ ] **`dotConfig`** — storage extension that holds editor feature configuration (readable via `editor.storage.dotConfig`) + > `block-editor/src/lib/extensions/dot-config/dot-config.extension.ts` + +- [ ] **`dotComands`** — utility extension exposing custom editor commands (e.g. `isNodeRegistered`) + > `block-editor/src/lib/extensions/dot-comands/dot-comands.extension.ts` + +--- + +### Table Operations UI + +TableKit is already registered and handles the table structure. What's missing is a UI for in-table operations. + +- [ ] **Table operations menu/dialog** — UI for merge/split cells, add/delete rows and columns, toggle header row on an existing table + > Reference: `block-editor/src/lib/elements/dot-table/dot-table.extension.ts` and `dot-table-cell-context-menu.plugin.ts` + > Note: Does not need to be a 1:1 copy — a toolbar, context menu, or floating panel are all valid approaches. + +--- + +### Context Menu + +- [ ] **`DotContextMenuComponent`** — right-click context menu with clipboard operations (copy/paste/cut) and HTML ↔ Markdown conversion + > `block-editor/src/lib/elements/dot-context-menu/dot-context-menu.component.ts` + +--- + +### Directives + +- [ ] **`FloatingMenuDirective`** — registers TipTap `FloatingMenuPlugin` on a host element and manages its lifecycle + > `block-editor/src/lib/shared/directives/floating/floating-menu.directive.ts` + +- [ ] **`NodeViewContentDirective`** — marks the DOM slot for TipTap node view content (`[tiptapNodeViewContent]`) + > `block-editor/src/lib/shared/directives/node-view-content/node-view-content.directive.ts` + +--- + +### Pipes + +- [ ] **`ContentletStatePipe`** — extracts live/working/deleted/hasLiveVersion state flags from a contentlet object + > `block-editor/src/lib/shared/pipes/contentlet-state/contentlet-state.pipe.ts` + +--- + +### Utilities + +- [ ] **`prosemirror.utils.ts`** — helpers: `findNodeByType`, `findParentNode`, `getNodeCoords`, node position utilities + > `block-editor/src/lib/shared/utils/prosemirror.utils.ts` + +- [ ] **`parser.utils.ts`** — `contentletToJSON` serializer for mapping node data to JSON output + > `block-editor/src/lib/shared/utils/parser.utils.ts` + +- [ ] **`constants.utils.ts`** — `NodeTypes` enum, `DEFAULT_LANG_ID`, `ContentletFilters` interface, block dependency map + > `block-editor/src/lib/shared/utils/constants.utils.ts` + +--- + +### Angular Node View Renderer + +- [ ] **`AngularNodeViewRenderer` / `AngularRenderer`** — bridge that mounts Angular components (with full DI) inside TipTap node views; needed for complex Angular-based nodes + > `block-editor/src/lib/AngularRenderer.ts` + > `block-editor/src/lib/NodeViewRenderer.ts` diff --git a/core-web/libs/new-block-editor/jest.config.ts b/core-web/libs/new-block-editor/jest.config.ts new file mode 100644 index 000000000000..01bf9067c12c --- /dev/null +++ b/core-web/libs/new-block-editor/jest.config.ts @@ -0,0 +1,25 @@ +/* eslint-disable */ +export default { + displayName: 'new-block-editor', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: {}, + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: [ + 'node_modules/(?!.*\\.mjs$|.*(y-protocols|lib0|y-prosemirror|@tiptap|marked))' + ], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ], + coveragePathIgnorePatterns: ['node_modules/'] +}; diff --git a/core-web/libs/new-block-editor/project.json b/core-web/libs/new-block-editor/project.json new file mode 100644 index 000000000000..ca9d09a6feff --- /dev/null +++ b/core-web/libs/new-block-editor/project.json @@ -0,0 +1,23 @@ +{ + "name": "new-block-editor", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/new-block-editor/src", + "prefix": "dotcms", + "tags": ["type:feature", "scope:new-block-editor"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/new-block-editor/jest.config.ts", + "verbose": false, + "tsConfig": "libs/new-block-editor/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/core-web/libs/new-block-editor/src/feature.md b/core-web/libs/new-block-editor/src/feature.md new file mode 100644 index 000000000000..d7fef684c3b3 --- /dev/null +++ b/core-web/libs/new-block-editor/src/feature.md @@ -0,0 +1,12 @@ +### Features + +1. Convert the block editor component into a form-friendly component using `ControlValueAccessor` +2. Add a **Grid block** that allows users to: + - Create columns + - Resize them + + Two references for this behavior: + - Local: `/Users/rjvelazco/Desktop/dotcms/core/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/GridBlock.tsx` + - Remote: https://github.com/hunghg255/reactjs-tiptap-editor/tree/main/src/extensions/Column +3. In the **Link form**, add a checkbox to let the user toggle whether the link should open in a new tab (`target="_blank"`) +4. Add a **selected state style** for the following node types: images, videos, and contentlets \ No newline at end of file diff --git a/core-web/libs/new-block-editor/src/index.ts b/core-web/libs/new-block-editor/src/index.ts new file mode 100644 index 000000000000..aac7aa758c6a --- /dev/null +++ b/core-web/libs/new-block-editor/src/index.ts @@ -0,0 +1,5 @@ +/** + * Experimental block editor playground — add feature exports from `lib/` as you refactor. + */ + +export * from './lib/editor/editor.component'; diff --git a/core-web/libs/new-block-editor/src/lib/app.config.ts b/core-web/libs/new-block-editor/src/lib/app.config.ts new file mode 100644 index 000000000000..052a99121608 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.config.ts @@ -0,0 +1,29 @@ +import Lara from '@primeuix/themes/lara'; + +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { providePrimeNG } from 'primeng/config'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideHttpClient(), + provideRouter(routes), + providePrimeNG({ + theme: { + preset: Lara, + options: { + darkModeSelector: '.dark', + cssLayer: { + name: 'primeng', + order: 'tailwind-base, primeng, tailwind-utilities' + } + } + } + }) + ] +}; diff --git a/core-web/libs/new-block-editor/src/lib/app.routes.ts b/core-web/libs/new-block-editor/src/lib/app.routes.ts new file mode 100644 index 000000000000..dc39edb5f23a --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/core-web/libs/new-block-editor/src/lib/app.spec.ts b/core-web/libs/new-block-editor/src/lib/app.spec.ts new file mode 100644 index 000000000000..dd4e6efa7ac7 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.spec.ts @@ -0,0 +1,24 @@ +import { TestBed } from '@angular/core/testing'; + +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', async () => { + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, block-editor'); + }); +}); diff --git a/core-web/libs/new-block-editor/src/lib/app.ts b/core-web/libs/new-block-editor/src/lib/app.ts new file mode 100644 index 000000000000..b96b610ce010 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { DotCMSEditorComponent as EditorComponent } from './editor/editor.component'; + +@Component({ + selector: 'dot-block-editor-root', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [EditorComponent], + template: ` + + ` +}) +export class App {} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/ai-content-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/ai-content-dialog.component.ts new file mode 100644 index 000000000000..304e8416e3de --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/ai-content-dialog.component.ts @@ -0,0 +1,189 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + signal +} from '@angular/core'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ButtonDirective } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { Skeleton } from 'primeng/skeleton'; + +import { Editor } from '@tiptap/core'; + +import { DotAiService } from '../services/dot-ai.service'; +import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; + +type Status = 'idle' | 'loading' | 'success' | 'error'; + +@Component({ + selector: 'dot-ai-content-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DialogModule, ReactiveFormsModule, ButtonDirective, Skeleton], + template: ` + +
+ +
+ + +
+ + +
+ @switch (status()) { + @case ('idle') { +

+ The generated text will appear here. +

+ } + @case ('loading') { +
+ + + + +
+ } + @case ('success') { +
+ } + @case ('error') { +

{{ errorMessage() }}

+ } + } +
+
+ + +
+ @if (status() === 'success') { + + + + } @else { + + + } +
+
+
+ ` +}) +export class AiContentDialogComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); + private readonly dotAi = inject(DotAiService); + + protected readonly promptControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }); + protected readonly status = signal('idle'); + protected readonly result = signal(''); + protected readonly errorMessage = signal(''); + + protected readonly generateDisabled = computed( + () => this.status() === 'loading' || this.promptControl.invalid + ); + + constructor() { + // Reset local state every time the dialog reopens. + effect(() => { + if (this.manager.aiContentOpen()) { + this.promptControl.reset(''); + this.status.set('idle'); + this.result.set(''); + this.errorMessage.set(''); + } + }); + } + + protected onVisibleChange(visible: boolean): void { + if (!visible) this.manager.closeAiContent(); + } + + protected onEnterKey(event: Event): void { + const ke = event as KeyboardEvent; + if (ke.shiftKey) return; // allow newlines with Shift+Enter + ke.preventDefault(); + if (!this.generateDisabled()) this.generate(); + } + + protected close(): void { + this.manager.closeAiContent(); + } + + protected discard(): void { + this.manager.closeAiContent(); + } + + protected generate(): void { + const prompt = this.promptControl.value.trim(); + if (!prompt) return; + this.status.set('loading'); + this.errorMessage.set(''); + this.dotAi.generateContent(prompt).subscribe({ + next: (content) => { + this.result.set(content); + this.status.set('success'); + }, + error: (err) => { + this.errorMessage.set(typeof err === 'string' ? err : 'Generation failed'); + this.status.set('error'); + } + }); + } + + protected insert(): void { + const html = this.result(); + if (!html) return; + this.editor().chain().focus().insertAINode(html).run(); + this.manager.closeAiContent(); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog.component.ts new file mode 100644 index 000000000000..acaf9c92d3b4 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/editor-dialog.component.ts @@ -0,0 +1,119 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Injector, + NgZone, + afterNextRender, + afterRenderEffect, + computed, + effect, + inject, + input, + 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. + * Auto-focuses the first focusable form element in projected content after the dialog is painted. + * Dialog content is projected via . + */ +@Component({ + selector: 'dot-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(); + + private readonly manager = inject(EditorDialogManagerService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly doc = inject(DOCUMENT); + private readonly injector = inject(Injector); + + 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 auto-focus runs 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) { + // Defer to next render so the visibility binding is painted + // before .focus() runs — otherwise it no-ops on a hidden element. + afterNextRender(() => this.focusFirstInput(), { injector: this.injector }); + } + }); + }); + + // 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); + }); + }); + } + + private focusFirstInput(): void { + const target = this.el.nativeElement.querySelector( + 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled])' + ) as HTMLElement | null; + target?.focus(); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/emoji-picker.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/emoji-picker.component.ts new file mode 100644 index 000000000000..fe33c227268a --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/emoji-picker.component.ts @@ -0,0 +1,59 @@ +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.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/image-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image-dialog.component.ts new file mode 100644 index 000000000000..a1f41422c525 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/image-dialog.component.ts @@ -0,0 +1,565 @@ +import { patchState, signalState } from '@ngrx/signals'; + +import { + ChangeDetectionStrategy, + Component, + NgZone, + computed, + effect, + inject, + input, + signal, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview'; + +import { take } from 'rxjs/operators'; + +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from './editor-dialog.component'; + +import { type DotImageData, DOT_IMAGE_NODE_NAME } from '../extensions/nodes/image.extension'; +import { + insertUploadPlaceholders, + replacePlaceholder, + removePlaceholder +} from '../extensions/nodes/upload-placeholder.extension'; +import { DotContentletService, type DotContentlet } from '../services/dot-contentlet.service'; +import { DotUploadService } from '../services/dot-upload.service'; +import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; +import { EditorStore } from '../store/editor.store'; + +type Tab = 'upload' | 'url' | 'dotcms'; + +interface DotcmsImagePickerState { + images: DotContentlet[]; + loading: boolean; + error: string | null; + totalRecords: number; + first: number; + pageSize: number; +} + +@Component({ + selector: 'dot-image-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + 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') { +
+ +
+ } + + @if (activeTab() === 'url') { +
+ + +
+ +
+
+ } + + @if (activeTab() === 'dotcms') { +
+
+ +
+ + +
+
+ + @if (dotcmsPicker.error()) { + + } @else { +
+ + +
+ @for (img of items; track img.inode) { + + } +
+
+
+
+ } +
+ } + } +
+
+ ` +}) +export class ImageDialogComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); + private readonly zone = inject(NgZone); + private readonly dotUpload = inject(DotUploadService); + private readonly dotContentlet = inject(DotContentletService); + private readonly store = inject(EditorStore); + + protected readonly activeTab = signal('url'); + protected readonly isEditing = computed( + () => this.manager.imagePayload()?.initialValues != null + ); + protected readonly dotcmsPicker = signalState({ + images: [], + loading: false, + error: null, + totalRecords: 0, + first: 0, + pageSize: 8 + }); + readonly dotcmsRows = 8; + readonly dotcmsRowsOptions: number[] = [8, 16, 24]; + + readonly urlControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }); + readonly dotcmsSearchControl = new FormControl('', { nonNullable: true }); + readonly editForm = new FormGroup({ + src: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + title: new FormControl('', { nonNullable: true }), + alt: new FormControl('', { nonNullable: true }) + }); + + constructor() { + // Pre-populate the edit form when opened in edit mode. + effect(() => { + const values = this.manager.imagePayload()?.initialValues; + if (values) { + untracked(() => + this.editForm.setValue({ + src: values.src, + title: values.title, + alt: values.alt + }) + ); + } + }); + + // Reset dialog UI state when the dialog closes. + effect(() => { + if (!this.manager.isOpen('image')) { + untracked(() => this.resetDialogUi()); + } + }); + } + + tabClass(tab: Tab): string { + const base = + 'flex min-w-0 flex-1 items-center justify-center gap-1.5 px-2 py-2.5 text-xs font-medium border-b-2 transition-colors sm:gap-2 sm:px-3 sm:text-sm'; + return this.activeTab() === tab + ? `${base} border-indigo-500 text-indigo-600 bg-white` + : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`; + } + + dotcmsThumbUrl(inode: string): string { + return `/dA/${inode}/120/max`; + } + + onSelectDotcmsTab(): void { + this.activeTab.set('dotcms'); + } + + onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void { + patchState(this.dotcmsPicker, { pageSize: event.rows }); + this.fetchDotcmsImagesPage(event.first, event.rows); + } + + runDotcmsSearch(): void { + patchState(this.dotcmsPicker, { first: 0 }); + this.fetchDotcmsImagesPage(0, this.dotcmsPicker.pageSize()); + } + + private fetchDotcmsImagesPage(first: number, rows: number): void { + patchState(this.dotcmsPicker, { loading: true, error: null }); + this.dotContentlet + .searchImages({ + text: this.dotcmsSearchControl.getRawValue(), + offset: first, + limit: rows, + languageId: this.store.languageId() + }) + .pipe(take(1)) + .subscribe({ + next: ({ contentlets, totalRecords }) => { + this.zone.run(() => + patchState(this.dotcmsPicker, { + images: contentlets, + totalRecords, + first, + loading: false + }) + ); + }, + error: () => { + this.zone.run(() => + patchState(this.dotcmsPicker, { + images: [], + totalRecords: 0, + error: 'Could not load images from dotCMS.', + loading: false + }) + ); + } + }); + } + + insertFromDotcms(contentlet: DotContentlet): void { + const src = `/dA/${contentlet.inode}`; + const label = contentlet.title || contentlet.identifier; + const data: DotImageData = { + identifier: contentlet.identifier, + inode: contentlet.inode, + languageId: contentlet.languageId, + title: contentlet.title ?? '', + asset: `/dA/${contentlet.inode}` + }; + this.editor() + .chain() + .focus() + .insertContent({ + type: DOT_IMAGE_NODE_NAME, + attrs: { + src, + title: label || null, + alt: label || null, + data + } + }) + .run(); + this.manager.close(); + } + + /** + * 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 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.dotUpload.uploadImage(file); + this.zone.run(() => + replacePlaceholder(editor, id, { + type: DOT_IMAGE_NODE_NAME, + attrs: { src, alt: file.name, data, title: null } + }) + ); + } catch (err) { + console.error('Image upload failed', err); + removePlaceholder(editor, id); + } + } + + onInsertUrl(): void { + if (this.urlControl.invalid) return; + 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(); + } + + onApplyEdit(): void { + if (this.editForm.controls.src.invalid) return; + const { src, title, alt } = this.editForm.getRawValue(); + this.editor() + .chain() + .focus() + .updateAttributes('dotImage', { + src, + title: title || null, + alt: alt || null + }) + .run(); + this.manager.close(); + } + + private resetDialogUi(): void { + 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/link-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link-dialog.component.ts new file mode 100644 index 000000000000..558ab127a9cd --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/link-dialog.component.ts @@ -0,0 +1,193 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from './editor-dialog.component'; + +import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; + +@Component({ + selector: 'dot-link-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, EditorDialogComponent], + template: ` + +
+
+

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

+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+
+
+
+ ` +}) +export class LinkDialogComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); + + protected readonly isEditing = computed( + () => this.manager.linkPayload()?.initialValues != null + ); + + readonly form = new FormGroup({ + href: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }), + displayText: new FormControl('', { nonNullable: true }), + openInNewTab: new FormControl(false, { nonNullable: true }) + }); + + constructor() { + // Pre-populate the form when opened in edit mode. + effect(() => { + const payload = this.manager.linkPayload(); + untracked(() => { + const values = payload?.initialValues; + if (values) { + this.form.setValue({ + href: values.href ?? '', + displayText: values.displayText ?? '', + openInNewTab: values.target === '_blank' + }); + } + }); + }); + + // Reset form when dialog closes. + effect(() => { + if (!this.manager.isOpen('link')) { + untracked(() => + this.form.reset({ href: '', displayText: '', openInNewTab: false }) + ); + } + }); + + // 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')); + }); + } + + onInsert(): void { + if (this.form.controls.href.invalid) return; + const { href, displayText, openInNewTab } = this.form.getRawValue(); + 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 linkEl = payload.linkEl; + const anchorPos = + payload.anchorPos ?? + (() => { + try { + return editor.view.posAtDOM(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/slash-menu/slash-menu-catalog.ts b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts new file mode 100644 index 000000000000..d6e4c576fafe --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts @@ -0,0 +1,351 @@ +import { firstValueFrom } from 'rxjs'; + +import type { Editor } from '@tiptap/core'; +import { SuggestionPluginKey } from '@tiptap/suggestion'; + +import { DOT_CONTENTLET_NODE_NAME } from '../../extensions/nodes/contentlet/contentlet.extension'; + +import type { BlockItem } from './slash-menu.types'; +import type { DotContentTypeService } from '../../services/dot-content-type.service'; +import type { DotContentletService } from '../../services/dot-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 { + openSubmenu(): void; + setItems(items: BlockItem[], commandFn: (item: BlockItem) => void): void; + close(): void; +} + +/** + * Use {@link raw} only when it is a Material Symbols ligature (snake_case); otherwise {@link fallback}. + * dotCMS may supply non-ligature labels for content types. + */ +function materialIconOrFallback(raw: string | null | undefined, fallback: string): string { + const v = (raw ?? '').trim(); + return /^[a-z][a-z0-9_]*$/.test(v) ? v : fallback; +} + +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: DotContentTypeService, + contentletService: DotContentletService, + getLanguageId: () => number +): BlockItem { + return { + label: 'Content type', + description: 'Insert a dotCMS content type', + icon: 'category', + keywords: ['content', 'type', 'dotcms', 'contenttype', 'model'], + blockName: 'contentlet', + keepRange: true, + onSelect: (editor, range) => { + // keepRange=true: deleteRange was skipped, suggestion session stays alive. + menuService.openSubmenu(); + + // Delete the query text (e.g. "content") but keep the "/" so Tiptap's + // suggestion resets to an empty query. The user can then type to filter + // content types. range.from is the position of "/", range.from+1 onwards + // is the query text. + if (range && range.from + 1 < range.to) { + editor + .chain() + .deleteRange({ from: range.from + 1, to: range.to }) + .run(); + } + + firstValueFrom(contentTypeService.fetchAll()) + .then((types) => { + const resolvedTypes = types ?? []; + // Content type items are plain display items — drill-down logic lives in the + // commandFn below (closure over editor and services). + const typeItems: BlockItem[] = + resolvedTypes.length > 0 + ? resolvedTypes.map((ct) => ({ + label: ct.name, + description: ct.description || ct.variable, + icon: materialIconOrFallback(ct.icon, 'folder_special'), + keywords: [ct.variable, ct.baseType.toLowerCase()] + })) + : [ + { + label: 'No content types found', + description: + 'No types returned from the API. Check permissions or configuration.', + icon: 'folder_off', + keywords: ['no', 'empty', 'content', 'types'], + isEmptyState: true + } + ]; + + menuService.setItems(typeItems, (selectedItem) => { + if (selectedItem.isEmptyState) { + clearActiveSuggestionRange(editor); + menuService.close(); + return; + } + + menuService.openSubmenu(); + + const slashMatch = SuggestionPluginKey.getState(editor.state); + if (slashMatch?.active && slashMatch.range.from + 1 < slashMatch.range.to) { + editor + .chain() + .deleteRange({ + from: slashMatch.range.from + 1, + to: slashMatch.range.to + }) + .run(); + } + + // keywords[0] is ct.variable (stored above) + const ctVariable = selectedItem.keywords[0]; + + firstValueFrom(contentletService.fetchByType(ctVariable, getLanguageId())) + .then((contentlets) => { + const resolvedContentlets = contentlets ?? []; + const contentletItems: BlockItem[] = resolvedContentlets.map( + (cl) => ({ + label: cl.title || cl.identifier, + description: cl.contentType, + icon: 'note_stack', + 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: { + // Full contentlet at runtime; the JSON-strip + // helper reduces it to {identifier, languageId} + // when the document is serialised for storage. + data: { + ...cl, + languageId: + (cl as { languageId?: number }) + .languageId ?? getLanguageId() + } + } + }) + .run(); + } + }) + ); + + const finalItems: BlockItem[] = + contentletItems.length === 0 + ? [ + { + label: 'No contentlets found', + description: `No ${selectedItem.label} contentlets available`, + icon: 'search_off', + keywords: ['no', 'empty', 'contentlets'], + isEmptyState: true + } + ] + : contentletItems; + + menuService.setItems(finalItems, (contentletItem) => { + if (contentletItem.onSelect) { + contentletItem.onSelect(editor); + } else { + clearActiveSuggestionRange(editor); + } + menuService.close(); + }); + }) + .catch(() => { + menuService.setItems( + [ + { + label: 'Could not load contentlets', + description: + 'The request failed. Check your connection and try again.', + icon: 'cloud_off', + keywords: ['error', 'contentlets'], + isEmptyState: true + } + ], + (contentletItem) => { + if (!contentletItem.onSelect) { + clearActiveSuggestionRange(editor); + } + menuService.close(); + } + ); + }); + }); + }) + .catch(() => { + menuService.setItems( + [ + { + label: 'Could not load content types', + description: + 'The request failed. Check your connection and API token.', + icon: 'cloud_off', + keywords: ['error', 'content', 'types'], + isEmptyState: true + } + ], + (item) => { + if (item.isEmptyState) { + clearActiveSuggestionRange(editor); + } + menuService.close(); + } + ); + }); + } + }; +} + +export const ALL_ITEMS: BlockItem[] = [ + { + label: 'Text', + description: 'Plain text paragraph', + icon: 'article', + keywords: ['paragraph', 'text'], + blockName: 'paragraph', + apply: (c) => c.setParagraph() + }, + { + label: 'Heading 1', + description: 'Top-level title or page heading', + icon: 'format_h1', + keywords: ['h1', 'heading', 'title'], + blockName: 'heading', + apply: (c) => c.setHeading({ level: 1 }) + }, + { + label: 'Heading 2', + description: 'Section heading', + icon: 'format_h2', + keywords: ['h2', 'heading', 'subtitle'], + blockName: 'heading', + apply: (c) => c.setHeading({ level: 2 }) + }, + { + label: 'Heading 3', + description: 'Subsection heading', + icon: 'format_h3', + keywords: ['h3', 'heading'], + blockName: 'heading', + apply: (c) => c.setHeading({ level: 3 }) + }, + { + label: 'Bullet List', + description: 'Unordered list of items', + icon: 'format_list_bulleted', + keywords: ['ul', 'list', 'bullets'], + blockName: 'bulletList', + apply: (c) => c.toggleBulletList() + }, + { + label: 'Ordered List', + description: 'Numbered list of steps or items', + icon: 'format_list_numbered', + keywords: ['ol', 'numbered', 'list'], + blockName: 'orderedList', + apply: (c) => c.toggleOrderedList() + }, + { + label: 'Blockquote', + description: 'Highlighted quote or callout', + icon: 'format_quote', + keywords: ['quote', 'callout', 'cite'], + blockName: 'blockquote', + apply: (c) => c.toggleBlockquote() + }, + { + label: 'Code Block', + description: 'Code snippet with syntax highlighting', + icon: 'code_blocks', + keywords: ['code', 'pre', 'snippet'], + blockName: 'codeBlock', + apply: (c) => c.setCodeBlock() + }, + { + label: 'Grid (2 columns)', + description: 'Two-column layout', + icon: 'view_column_2', + keywords: ['grid', 'columns', 'layout', 'two-column'], + blockName: 'gridBlock', + apply: (c) => c.insertGrid() + } +]; + +/** Slash entries that open a floating dialog before mutating the document. */ +export function createSlashDialogBlockItems( + dialogManager: EditorDialogManagerService +): BlockItem[] { + return [ + { + label: 'Table', + description: 'Organize data in rows and columns', + icon: 'table', + keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], + blockName: 'table', + onSelect: (editor) => { + const { from } = editor.state.selection; + const coords = editor.view.coordsAtPos(from); + const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); + dialogManager.open('table', () => rect); + } + }, + { + label: 'Image', + description: 'Add a photo or graphic', + icon: 'image', + keywords: ['image', 'photo', 'picture', 'upload', 'url'], + blockName: 'image', + onSelect: (editor) => { + const { from } = editor.state.selection; + const coords = editor.view.coordsAtPos(from); + const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); + dialogManager.openImage(() => rect); + } + }, + { + label: 'Video', + description: 'Embed a video from a link or file', + icon: 'videocam', + keywords: ['video', 'mp4', 'upload', 'url', 'media'], + blockName: 'video', + onSelect: (editor) => { + const { from } = editor.state.selection; + const coords = editor.view.coordsAtPos(from); + const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); + dialogManager.open('video', () => rect); + } + } + ]; +} + +/** + * Slash entry for "Ask AI". Returned as an array so callers can spread it conditionally + * based on the AI plugin install flag (`store.aiInstalled()`). + */ +export function createSlashAiBlockItems(dialogManager: EditorDialogManagerService): BlockItem[] { + return [ + { + label: 'Ask AI', + description: 'Generate text with AI', + icon: 'auto_awesome', + keywords: ['ai', 'generate', 'gpt', 'prompt', 'llm', 'chat'], + blockName: 'aiContent', + onSelect: () => dialogManager.openAiContent() + } + ]; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.component.ts new file mode 100644 index 000000000000..fad7e8383e5a --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.component.ts @@ -0,0 +1,161 @@ +import { computePosition, flip, offset, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + effect, + inject, + signal, + untracked +} from '@angular/core'; + +import { SlashMenuService } from './slash-menu.service'; + +@Component({ + selector: 'dot-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); + private readonly document = inject(DOCUMENT); + + protected onHostPointerDownCapture(): void { + this.service.prepareMenuPointerInteraction(); + } + + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + // Starts false on every open; prevents a 0,0 flash before computePosition resolves + protected readonly positioned = signal(false); + private readonly scrollTick = signal(0); + + constructor() { + effect((onCleanup) => { + if (!this.service.isOpen()) return; + + const onScroll = () => this.scrollTick.update((n) => n + 1); + this.document.addEventListener('scroll', onScroll, { passive: true, capture: true }); + onCleanup(() => { + this.document.removeEventListener('scroll', onScroll, { capture: true }); + }); + }); + + // Keep the active option visible when arrow keys move the selection. + afterRenderEffect(() => { + if (!this.service.isOpen()) return; + const i = this.service.activeIndex(); + const target = this.el.nativeElement.querySelector( + `#slash-opt-${i}` + ) as HTMLElement | null; + target?.scrollIntoView({ block: 'nearest' }); + }); + + afterRenderEffect(() => { + this.scrollTick(); + const isOpen = this.service.isOpen(); + const clientRectFn = this.service.clientRectFn(); + + if (!isOpen || !clientRectFn) { + untracked(() => this.positioned.set(false)); + return; + } + + const virtualRef = { + getBoundingClientRect: () => clientRectFn() ?? new DOMRect() + }; + + // Host uses `position: fixed` (Tailwind `fixed`). Floating UI must use the same + // strategy or `left`/`top` are interpreted in the wrong space (large offset vs `/`). + computePosition(virtualRef, this.el.nativeElement, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [offset(4), flip(), shift({ padding: 8 })] + }).then(({ x, y }) => { + this.zone.run(() => { + untracked(() => { + this.floatX.set(x); + this.floatY.set(y); + this.positioned.set(true); + }); + }); + }); + }); + } + + itemClass(i: number): string { + const base = + 'flex w-full cursor-pointer items-center gap-3 rounded px-2 py-1.5 transition-colors'; + return this.service.activeIndex() === i + ? `${base} bg-blue-50` + : `${base} hover:bg-gray-100`; + } + + onMouseMove(i: number): void { + if (i !== this.service.activeIndex()) { + this.service.activeIndex.set(i); + } + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.service.ts new file mode 100644 index 000000000000..2209c63488e0 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.service.ts @@ -0,0 +1,263 @@ +import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; + +import type { Editor } from '@tiptap/core'; + +import { + ALL_ITEMS, + createContentTypeItem, + createSlashAiBlockItems, + createSlashDialogBlockItems +} from './slash-menu-catalog'; + +import { DotContentTypeService } from '../../services/dot-content-type.service'; +import { DotContentletService } from '../../services/dot-contentlet.service'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; +import { EditorStore } from '../../store/editor.store'; + +import type { BlockItem } from './slash-menu.types'; + +export type { BlockItem } from './slash-menu.types'; +export { ALL_ITEMS } from './slash-menu-catalog'; + +/** + * Coordinates the TipTap slash-command floating menu: item catalog, filtering, + * sub-menu loading for content types, keyboard navigation, and editor focus + * so the suggestion session stays valid when picking from the overlay. + */ +@Injectable() +export class SlashMenuService { + private readonly zone = inject(NgZone); + private readonly store = inject(EditorStore); + private readonly dialogManager = inject(EditorDialogManagerService); + private readonly contentTypeService = inject(DotContentTypeService); + private readonly contentletService = inject(DotContentletService); + + private readonly dialogBlockItems = createSlashDialogBlockItems(this.dialogManager); + private readonly aiBlockItems = createSlashAiBlockItems(this.dialogManager); + + private readonly contentTypeItem = createContentTypeItem( + this, + this.contentTypeService, + this.contentletService, + () => this.store.languageId() + ); + + /** + * Returns menu items for the text after `/`, respecting allowed block types from {@link EditorStore}. + * While a sub-menu is open, filters the cached sub-menu list instead of the root catalog. + * + * @param query Text after the slash; matched case-insensitively against labels and keywords. + */ + filterItems(query: string): BlockItem[] { + 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 aiItems = this.store.aiInstalled() === true ? this.aiBlockItems : []; + const all = [this.contentTypeItem, ...ALL_ITEMS, ...this.dialogBlockItems, ...aiItems]; + + const filtered = all.filter( + (item) => + !item.blockName || + item.blockName === 'paragraph' || + this.store.isAllowed(item.blockName) + ); + + const q = query.toLowerCase().trim(); + if (!q) return filtered; + return filtered.filter( + (item) => + item.label.toLowerCase().includes(q) || item.keywords.some((k) => k.includes(q)) + ); + } + + /** Options currently shown in the slash menu (root or sub-menu). */ + readonly items = signal([]); + /** Whether the floating menu is visible. */ + readonly isOpen = signal(false); + /** True while async sub-menu items (e.g. content types) are loading. */ + readonly isLoading = signal(false); + /** Index of the highlighted row for keyboard navigation. */ + readonly activeIndex = signal(0); + /** TipTap suggestion anchor: resolves the caret rect for positioning the overlay. */ + readonly clientRectFn = signal<(() => DOMRect | null) | null>(null); + /** Stable `id` for the active row, or `null` when the menu is closed or empty (a11y). */ + 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 while the content-type (or similar) sub-menu is open. + * Kept separately from {@link items} so {@link filterItems} can re-filter as the user types. + */ + private subMenuAllItems: BlockItem[] = []; + + /** + * Called by the slash-command extension so UI interactions can refocus the editor before running commands. + * + * @param editor Active TipTap editor instance for this menu. + */ + attachEditor(editor: Editor): void { + this.editor = editor; + } + + /** Clears the editor reference when the slash suggestion plugin is torn down. */ + detachEditor(): void { + this.editor = null; + } + + /** + * Call from `pointerdown` capture on the menu so the editor is focused before the event target runs. + */ + prepareMenuPointerInteraction(): void { + this.editor?.view.focus(); + } + + /** + * Opens the slash menu with an initial item list and TipTap suggestion wiring. + * + * @param items Rows to display immediately. + * @param clientRectFn Anchor for overlay position; from TipTap suggestion props. + * @param commandFn Invoked when the user confirms a row (Enter / click). + */ + 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); + }); + } + + /** + * Refreshes the visible rows and/or anchor while a suggestion session is active. + * In a sub-menu, only updates {@link items} and the active index — preserves {@link commandFn} + * because TipTap's callback would otherwise call `deleteRange` on the slash trigger. + * + * @param items Latest filtered list (from {@link filterItems}). + * @param clientRectFn Updated caret rect, ignored while in a sub-menu. + * @param commandFn Latest TipTap command callback, ignored while in a sub-menu. + */ + update( + items: BlockItem[], + clientRectFn: (() => DOMRect | null) | null, + commandFn: (item: BlockItem) => void + ): void { + if (this.isInSubmenu) { + 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); + }); + } + + /** Hides the menu, clears sub-menu state, and drops TipTap command wiring. */ + 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. + * + * @param items Full sub-menu list; also stored for re-filtering while the user types. + * @param commandFn Handler for picks in this sub-menu. + */ + 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); + }); + } + + /** + * Confirms a menu row: refocuses the editor then runs the active command callback. + * + * Focusing first avoids losing the `/…` suggestion range when the overlay steals focus, + * which would exit `@tiptap/suggestion` and call {@link close} before the command runs. + * + * @param item Row the user chose. + */ + select(item: BlockItem): void { + this.editor?.view.focus(); + this.commandFn?.(item); + } + + /** + * Handles arrow keys, Enter, and Escape while the menu is open. + * + * @param event Native keyboard event from the editor host. + * @returns `true` if the event was consumed and should not propagate. + */ + 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/components/slash-menu/slash-menu.types.ts b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.types.ts new file mode 100644 index 000000000000..aa3dad8ec52a --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu.types.ts @@ -0,0 +1,23 @@ +import type { ChainedCommands, Editor } from '@tiptap/core'; + +export interface BlockItem { + label: string; + description: string; + icon?: string; + keywords: string[]; + /** + * When true, the slash trigger text is NOT deleted from the editor on selection. + * The Tiptap suggestion session stays alive, so keyboard navigation keeps working. + * The item's onSelect is responsible for cleaning up the range later. + */ + keepRange?: boolean; + /** + * When true, choosing this row only clears the slash trigger and closes the menu + * (no document insert / no drill-down). Used for empty and error rows in submenus. + */ + isEmptyState?: boolean; + /** Canonical block name used for allowedBlocks filtering. Absent = always shown. */ + blockName?: string; + apply?: (chain: ChainedCommands) => ChainedCommands; + onSelect?: (editor: Editor, range?: { from: number; to: number }) => void; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-dialog.component.ts new file mode 100644 index 000000000000..a08476b8b66e --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-dialog.component.ts @@ -0,0 +1,130 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from './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-table-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, EditorDialogComponent], + template: ` + +
+
+

+ Insert Table +

+ +
+
+ + +
+
+ + +
+
+ + + +
+ + +
+
+
+
+ ` +}) +export class TableDialogComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); + protected readonly maxValue = MAX_VALUE; + + 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(() => { + if (!this.manager.isOpen('table')) { + untracked(() => + this.form.reset({ + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + withHeaderRow: true + }) + ); + } + }); + } + + onApply(): void { + if (this.form.invalid) return; + 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/toolbar/editor-toolbar-state.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar-state.service.ts new file mode 100644 index 000000000000..a18f9982e8d2 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar-state.service.ts @@ -0,0 +1,124 @@ +import { Injectable, NgZone, inject, signal } from '@angular/core'; + +import { Editor } from '@tiptap/core'; +import { NodeSelection } from '@tiptap/pm/state'; + +import type { ContentletEditEvent } from '../../extensions/nodes/contentlet/contentlet.extension'; + +@Injectable({ providedIn: 'root' }) +export class EditorToolbarStateService { + private readonly zone = inject(NgZone); + + readonly isBold = signal(false); + readonly isItalic = signal(false); + readonly isStrike = signal(false); + readonly isCode = signal(false); + readonly isBulletList = signal(false); + readonly isOrderedList = signal(false); + readonly isBlockquote = signal(false); + readonly isCodeBlock = signal(false); + readonly headingLevel = signal(null); + readonly isLink = signal(false); + readonly canUndo = signal(false); + readonly canRedo = signal(false); + readonly canIndent = signal(false); + readonly canOutdent = signal(false); + readonly isImageSelected = signal(false); + readonly imageTextWrap = signal(null); + readonly imageTextAlign = signal(null); + readonly textAlign = signal<'left' | 'center' | 'right' | 'justify'>('left'); + readonly isSuperscript = signal(false); + readonly isSubscript = signal(false); + readonly isInTable = signal(false); + readonly canMergeCells = signal(false); + readonly canSplitCell = signal(false); + readonly selectedContentlet = signal(null); + + connect(editor: Editor): () => void { + const update = () => { + this.zone.run(() => { + this.isBold.set(editor.isActive('bold')); + this.isItalic.set(editor.isActive('italic')); + this.isStrike.set(editor.isActive('strike')); + this.isCode.set(editor.isActive('code')); + this.isBulletList.set(editor.isActive('bulletList')); + this.isOrderedList.set(editor.isActive('orderedList')); + this.isBlockquote.set(editor.isActive('blockquote')); + this.isCodeBlock.set(editor.isActive('codeBlock')); + this.isLink.set(editor.isActive('link')); + this.isImageSelected.set(editor.isActive('dotImage')); + this.imageTextWrap.set( + editor.isActive('dotImage') + ? (editor.getAttributes('dotImage').textWrap ?? null) + : null + ); + this.imageTextAlign.set( + editor.isActive('dotImage') + ? (editor.getAttributes('dotImage').textAlign ?? null) + : null + ); + this.canUndo.set(editor.can().undo()); + this.canRedo.set(editor.can().redo()); + this.canIndent.set(editor.can().sinkListItem('listItem')); + this.canOutdent.set(editor.can().liftListItem('listItem')); + this.textAlign.set( + editor.isActive({ textAlign: 'center' }) + ? 'center' + : editor.isActive({ textAlign: 'right' }) + ? 'right' + : editor.isActive({ textAlign: 'justify' }) + ? 'justify' + : 'left' + ); + this.isSuperscript.set(editor.isActive('superscript')); + this.isSubscript.set(editor.isActive('subscript')); + this.isInTable.set(editor.isActive('table')); + this.canMergeCells.set(editor.can().mergeCells()); + this.canSplitCell.set(editor.can().splitCell()); + + const { selection } = editor.state; + const contentletNode = + selection instanceof NodeSelection && selection.node.type.name === 'dotContent' + ? selection.node + : null; + const data = contentletNode?.attrs['data'] as + | { + identifier?: string; + inode?: string; + contentType?: string; + title?: string; + } + | null + | undefined; + this.selectedContentlet.set( + contentletNode && data + ? { + identifier: data.identifier ?? '', + inode: data.inode ?? '', + contentType: data.contentType ?? '', + title: data.title ?? '' + } + : null + ); + + 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/components/toolbar/markdown.utils.ts b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/markdown.utils.ts new file mode 100644 index 000000000000..a64c8581807d --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/markdown.utils.ts @@ -0,0 +1,89 @@ +import { marked } from 'marked'; +import TurndownService from 'turndown'; + +/** + * Turndown options. Matches the legacy block-editor's MARKDOWN_CONFIG so output is consistent + * across editors when both are in use during the migration. + */ +const MARKDOWN_CONFIG: TurndownService.Options = { + headingStyle: 'atx', + hr: '---', + bulletListMarker: '-', + codeBlockStyle: 'fenced', + emDelimiter: '_' +}; + +/** + * Renders an HTML table as a Markdown pipe-table. + * + * We override turndown's default table handling because TipTap/ProseMirror generates HTML with + * nested `

` inside cells, `` elements, and other structures the default converter + * doesn't handle cleanly. This walks rows directly and extracts `textContent`. + */ +function processTable(table: HTMLTableElement): string { + const rows = Array.from(table.querySelectorAll('tr')); + if (rows.length === 0) return ''; + + const markdownRows: string[] = []; + rows.forEach((row, index) => { + const cells = Array.from(row.querySelectorAll('td, th')); + const cellContents = cells.map((cell) => cell.textContent?.trim() ?? ''); + markdownRows.push('| ' + cellContents.join(' | ') + ' |'); + + if (index === 0 && row.querySelector('th')) { + markdownRows.push('| ' + cellContents.map(() => '---').join(' | ') + ' |'); + } + }); + + return markdownRows.join('\n'); +} + +/** + * Strips editor chrome that shouldn't appear in copied Markdown: + * - Buttons (e.g. table cell arrows) + * - Embedded contentlet blocks (no clean Markdown representation) + * - `` / `` (purely structural) + * - Inline `style` attributes + * + * Also unwraps single-paragraph table cells so turndown sees flat text instead of `

` nesting. + */ +function cleanHtmlForMarkdown(html: string): string { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + tempDiv.querySelectorAll('button').forEach((el) => el.remove()); + // dotContent node uses `data-type="dot-content"` (legacy used `data-dotCMS-contentlet`). + tempDiv.querySelectorAll('[data-type="dot-content"]').forEach((el) => el.remove()); + tempDiv.querySelectorAll('colgroup').forEach((el) => el.remove()); + + tempDiv.querySelectorAll('td, th').forEach((cell) => { + const paragraphs = cell.querySelectorAll('p'); + if (paragraphs.length === 1 && paragraphs[0].parentElement === cell) { + cell.innerHTML = paragraphs[0].innerHTML; + } + }); + + tempDiv.querySelectorAll('[style]').forEach((el) => el.removeAttribute('style')); + + return tempDiv.innerHTML; +} + +function createTurndownService(): TurndownService { + const service = new TurndownService(MARKDOWN_CONFIG); + service.addRule('tables', { + filter: 'table', + replacement: (_content, node) => '\n\n' + processTable(node as HTMLTableElement) + '\n\n' + }); + return service; +} + +/** Converts editor HTML to Markdown. */ +export function htmlToMarkdown(html: string): string { + return createTurndownService().turndown(cleanHtmlForMarkdown(html)); +} + +/** Converts Markdown to HTML for insertion via TipTap's `insertContent`. */ +export function markdownToHtml(md: string): string { + // marked.parse returns string in sync mode (default). Cast to satisfy `string | Promise` union. + return marked.parse(md, { async: false }) as string; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.ts new file mode 100644 index 000000000000..d130ba572005 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.ts @@ -0,0 +1,1115 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, + output +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import type { TooltipOptions } from 'primeng/api'; +import { Select } from 'primeng/select'; +import { Tooltip } from 'primeng/tooltip'; + +import { Editor } from '@tiptap/core'; +import { DOMSerializer } from '@tiptap/pm/model'; + +import { EditorToolbarStateService } from './editor-toolbar-state.service'; +import { htmlToMarkdown, markdownToHtml } from './markdown.utils'; + +import { BLOCK_TARGET_KEY } from '../../extensions/selection-preserve.extension'; +import { EditorDialogManagerService } from '../../services/editor-dialog-manager.service'; +import { EditorStore } from '../../store/editor.store'; + +import type { ContentletEditEvent } from '../../extensions/nodes/contentlet/contentlet.extension'; + +@Component({ + selector: 'dot-toolbar', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, Select, Tooltip], + 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-2 rounded-t-lg', + '(keydown)': 'onToolbarKeyDown($event)' + }, + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if (showBlockFormatsGroup()) { + + + + @if (isAllowed('bulletList')) { + + } + @if (isAllowed('orderedList')) { + + } + @if (isAllowed('blockquote')) { + + } + @if (isAllowed('codeBlock')) { + + } + } + + + + + + + + + + + + @if (isAllowed('horizontalRule')) { + + } + + @if (showInsertGroup()) { + + + + @if (isAllowed('link')) { + + } + @if (isAllowed('image')) { + + } + @if (isAllowed('video')) { + + } + @if (isAllowed('table')) { + + } + + @if (isAllowed('table')) { + + + + + + + + + + + + + + + + + + + + + + + + + + + } + @if (isAllowed('emoji')) { + + } + } + + + + + + + + + + ` +}) +export class ToolbarComponent implements OnDestroy { + protected readonly state = inject(EditorToolbarStateService); + protected readonly store = inject(EditorStore); + private readonly dialogManager = inject(EditorDialogManagerService); + + readonly editor = input.required(); + readonly isFullscreen = input(false); + + /** + * Fullscreen editor shell uses `z-[9998]` on its backdrop; PrimeNG tooltips append to `document.body` + * with a much lower default z-index, so they render under the overlay. Bump only while fullscreen. + */ + protected readonly overlayTooltipOptions = computed( + (): TooltipOptions => + this.isFullscreen() ? { tooltipZIndex: '10050' } : { tooltipZIndex: 'auto' } + ); + + readonly fullscreenToggle = output(); + readonly contentletEdit = output(); + + private cleanupFn: (() => void) | null = null; + + constructor() { + effect(() => { + this.cleanupFn?.(); + this.cleanupFn = this.state.connect(this.editor()); + }); + } + + ngOnDestroy(): void { + this.cleanupFn?.(); + } + + protected btnClass(active: boolean): string { + const base = + 'flex h-9 w-9 cursor-pointer items-center justify-center rounded text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed'; + return active + ? `${base} bg-indigo-100 text-indigo-700` + : `${base} text-gray-600 hover:bg-gray-100 hover:text-gray-900`; + } + + protected readonly blockTypeValue = computed(() => { + const level = this.state.headingLevel(); + return level === null ? 'paragraph' : `h${level}`; + }); + + /** + * Flattens p-select to fit the toolbar's icon-button aesthetic. + * Uses PrimeNG design tokens (--p-select-*) instead of !important — tokens + * win against unlayered component CSS without specificity hacks. + */ + protected readonly selectPt = { + root: { + class: 'h-9 rounded hover:bg-gray-100', + style: { + '--p-select-border-color': 'transparent', + '--p-select-hover-border-color': 'transparent', + '--p-select-focus-border-color': 'transparent', + '--p-select-background': 'transparent', + '--p-select-hover-background': 'transparent', + '--p-select-shadow': 'none' + } + }, + label: { class: 'flex items-center text-sm text-gray-700 leading-none' }, + dropdown: { class: 'flex items-center text-gray-500' } + }; + + protected readonly blockTypeOptions = computed(() => { + const opts: { label: string; value: string }[] = [ + { label: 'Paragraph', value: 'paragraph' } + ]; + if (this.store.isAllowed('heading')) { + opts.push( + { label: 'Heading 1', value: 'h1' }, + { label: 'Heading 2', value: 'h2' }, + { label: 'Heading 3', value: 'h3' } + ); + } + return opts; + }); + + // ── allowedBlocks helpers ──────────────────────────────────────────────── + + protected isAllowed(block: string): boolean { + return this.store.isAllowed(block); + } + + protected readonly showBlockFormatsGroup = computed( + () => + this.store.isAllowed('bulletList') || + this.store.isAllowed('orderedList') || + this.store.isAllowed('blockquote') || + this.store.isAllowed('codeBlock') + ); + + protected readonly showInsertGroup = computed( + () => + this.store.isAllowed('link') || + this.store.isAllowed('image') || + this.store.isAllowed('video') || + this.store.isAllowed('table') || + this.store.isAllowed('emoji') + ); + + // When an image is selected, the alignment buttons reflect the image's textAlign + // (defaulting to 'left' when unset, matching paragraph behavior). Otherwise they + // reflect the standard text-align state from the TextAlign extension. + protected readonly effectiveAlign = computed(() => + this.state.isImageSelected() + ? (this.state.imageTextAlign() ?? 'left') + : this.state.textAlign() + ); + + // ── History ────────────────────────────────────────────────────────────── + + protected undo(): void { + this.editor().chain().focus().undo().run(); + } + + protected redo(): void { + this.editor().chain().focus().redo().run(); + } + + // ── Block type ─────────────────────────────────────────────────────────── + + protected setBlockType(value: string): void { + 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(); + } + } + + /** Highlights the cursor's block while the block-type select is open. */ + protected setBlockTargetActive(active: boolean): void { + const editor = this.editor(); + editor.view.dispatch(editor.state.tr.setMeta(BLOCK_TARGET_KEY, { active })); + } + + // ── Markdown copy / paste ──────────────────────────────────────────────── + + /** Copies the selection (or whole doc if no selection) as Markdown. */ + protected async copyAsMarkdown(): Promise { + const editor = this.editor(); + const html = this.getSelectedHtmlOrAll(editor); + if (!html) return; + try { + await navigator.clipboard.writeText(htmlToMarkdown(html)); + } catch (err) { + console.warn('Copy as Markdown failed', err); + } finally { + editor.view.focus(); + } + } + + /** Reads Markdown from the clipboard and inserts it as rich content at the cursor. */ + protected async pasteFromMarkdown(): Promise { + const editor = this.editor(); + try { + const text = await navigator.clipboard.readText(); + if (!text) return; + editor.chain().focus().insertContent(markdownToHtml(text)).run(); + } catch (err) { + console.warn('Paste from Markdown failed', err); + } + } + + /** Returns the selection's HTML, or the entire document's HTML when the selection is empty. */ + private getSelectedHtmlOrAll(editor: Editor): string { + const { from, to, empty } = editor.state.selection; + if (empty) return editor.getHTML(); + const slice = editor.state.doc.cut(from, to); + const fragment = DOMSerializer.fromSchema(editor.schema).serializeFragment(slice.content); + const div = document.createElement('div'); + div.appendChild(fragment); + return div.innerHTML; + } + + // ── 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.dialogManager.isOpen('link')) { + this.dialogManager.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.dialogManager.openLink(() => linkEl.getBoundingClientRect(), { + initialValues: { href, displayText, target: linkMark.attrs['target'] ?? null }, + linkEl, + anchorPos + }); + } else { + // Insert mode — anchor to the toolbar button + const selectedText = empty ? '' : editor.state.doc.textBetween(from, to); + this.dialogManager.openLink( + () => btn.getBoundingClientRect(), + selectedText + ? { initialValues: { href: '', displayText: selectedText } } + : undefined + ); + } + } + + protected openImageDialog(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (this.dialogManager.isOpen('image')) { + this.dialogManager.close(); + return; + } + const btn = event.currentTarget as HTMLElement; + this.dialogManager.openImage(() => btn.getBoundingClientRect()); + } + + protected openVideoDialog(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (this.dialogManager.isOpen('video')) { + this.dialogManager.close(); + return; + } + const btn = event.currentTarget as HTMLElement; + this.dialogManager.open('video', () => btn.getBoundingClientRect()); + } + + protected openTableDialog(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + if (this.dialogManager.isOpen('table')) { + this.dialogManager.close(); + return; + } + const btn = event.currentTarget as HTMLElement; + this.dialogManager.open('table', () => btn.getBoundingClientRect()); + } + + protected openEmojiPicker(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + const btn = event.currentTarget as HTMLElement; + this.dialogManager.toggle('emoji', () => btn.getBoundingClientRect()); + } + + // ── Text alignment ─────────────────────────────────────────────────────── + + protected setTextAlign(align: 'left' | 'center' | 'right' | 'justify'): void { + const editor = this.editor(); + if (this.state.isImageSelected()) { + // Justify isn't meaningful for an image; mirror the old node's behavior + if (align === 'justify') return; + editor.chain().focus().setImageTextAlign(align).run(); + return; + } + editor.chain().focus().setTextAlign(align).run(); + } + + // ── Superscript / Subscript ────────────────────────────────────────────── + + protected toggleSuperscript(): void { + this.editor().chain().focus().unsetSubscript().toggleSuperscript().run(); + } + + protected toggleSubscript(): void { + this.editor().chain().focus().unsetSuperscript().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 { + this.editor().chain().focus().setImageTextWrap(value).run(); + } + + // ── Edit image properties ──────────────────────────────────────────────── + + 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 !== 'dotImage') return; + + const btn = event.currentTarget as HTMLElement; + this.dialogManager.openImage(() => btn.getBoundingClientRect(), { + initialValues: { + src: node.attrs['src'], + title: node.attrs['title'] ?? '', + alt: node.attrs['alt'] ?? '' + } + }); + } + + // ── Table actions ──────────────────────────────────────────────────────── + + protected tableInsertRowAbove(): void { + this.editor().chain().focus().addRowBefore().run(); + } + + protected tableInsertRowBelow(): void { + this.editor().chain().focus().addRowAfter().run(); + } + + protected tableInsertColLeft(): void { + this.editor().chain().focus().addColumnBefore().run(); + } + + protected tableInsertColRight(): void { + this.editor().chain().focus().addColumnAfter().run(); + } + + protected tableMerge(): void { + this.editor().chain().focus().mergeCells().run(); + } + + protected tableSplit(): void { + this.editor().chain().focus().splitCell().run(); + } + + protected tableToggleRowHeader(): void { + this.editor().chain().focus().toggleHeaderRow().run(); + } + + protected tableToggleColHeader(): void { + this.editor().chain().focus().toggleHeaderColumn().run(); + } + + protected tableDeleteRow(): void { + this.editor().chain().focus().deleteRow().run(); + } + + protected tableDeleteCol(): void { + this.editor().chain().focus().deleteColumn().run(); + } + + protected tableDeleteTable(): void { + this.editor().chain().focus().deleteTable().run(); + } + + // ── Keyboard navigation (roving tabindex) ──────────────────────────────── + + protected onToolbarKeyDown(event: KeyboardEvent): void { + if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; + const els = Array.from( + (event.currentTarget as HTMLElement).querySelectorAll( + 'button:not([disabled]), select' + ) + ); + const idx = els.indexOf(document.activeElement as HTMLElement); + if (idx === -1) return; + event.preventDefault(); + const next = + event.key === 'ArrowRight' + ? (idx + 1) % els.length + : (idx - 1 + els.length) % els.length; + els[next]?.focus(); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/video-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video-dialog.component.ts new file mode 100644 index 000000000000..d6682fc3da2d --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/video-dialog.component.ts @@ -0,0 +1,476 @@ +import { + ChangeDetectionStrategy, + Component, + NgZone, + effect, + inject, + input, + signal, + untracked +} from '@angular/core'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview'; + +import { take } from 'rxjs/operators'; + +import { Editor } from '@tiptap/core'; + +import { EditorDialogComponent } from './editor-dialog.component'; + +import { DOT_VIDEO_NODE_NAME } from '../extensions/nodes/video.extension'; +import { DotContentletService, type DotContentlet } from '../services/dot-contentlet.service'; +import { DotUploadService } from '../services/dot-upload.service'; +import { EditorDialogManagerService } from '../services/editor-dialog-manager.service'; +import { EditorStore } from '../store/editor.store'; + +type Tab = 'upload' | 'url' | 'dotcms'; + +// Matches youtube.com/watch?v=…, youtu.be/…, and the youtube-nocookie variant. +const YOUTUBE_URL_REGEX = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube-nocookie\.com\/embed\/)/i; + +@Component({ + selector: 'dot-video-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, DataViewModule, EditorDialogComponent], + template: ` + +

+ +
+ + + +
+ + + @if (activeTab() === 'upload') { +
+ +
+ } + + + @if (activeTab() === 'url') { +
+
+ + +
+
+ + +
+
+ +
+
+ } + + @if (activeTab() === 'dotcms') { +
+
+ +
+ + +
+
+ + @if (dotcmsError()) { + + } @else { +
+ + +
+ @for (vid of items; track vid.inode) { + + } +
+
+
+
+ } +
+ } +
+
+ ` +}) +export class VideoDialogComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorDialogManagerService); + private readonly zone = inject(NgZone); + private readonly dotUpload = inject(DotUploadService); + private readonly dotContentlet = inject(DotContentletService); + private readonly store = inject(EditorStore); + + protected readonly activeTab = signal('url'); + protected readonly uploading = signal(false); + protected readonly dotcmsVideos = signal([]); + protected readonly dotcmsLoading = signal(false); + protected readonly dotcmsError = signal(null); + protected readonly dotcmsTotalRecords = signal(0); + protected readonly dotcmsFirst = signal(0); + protected readonly dotcmsPageSize = signal(8); + readonly dotcmsRows = 8; + readonly dotcmsRowsOptions: number[] = [8, 16, 24]; + + readonly urlControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }); + readonly titleControl = new FormControl('', { nonNullable: true }); + readonly dotcmsSearchControl = new FormControl('', { nonNullable: true }); + + constructor() { + effect(() => { + if (!this.manager.isOpen('video')) { + untracked(() => { + this.activeTab.set('url'); + this.urlControl.reset(''); + this.titleControl.reset(''); + this.dotcmsSearchControl.reset(''); + this.dotcmsVideos.set([]); + this.dotcmsError.set(null); + this.dotcmsLoading.set(false); + this.dotcmsTotalRecords.set(0); + this.dotcmsFirst.set(0); + this.dotcmsPageSize.set(this.dotcmsRows); + this.uploading.set(false); + }); + } + }); + } + + tabClass(tab: Tab): string { + const base = + 'flex min-w-0 flex-1 items-center justify-center gap-1.5 px-2 py-2.5 text-xs font-medium border-b-2 transition-colors sm:gap-2 sm:px-3 sm:text-sm'; + return this.activeTab() === tab + ? `${base} border-indigo-500 text-indigo-600 bg-white` + : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`; + } + + dotcmsVideoPreviewUrl(inode: string): string { + return `/dA/${inode}`; + } + + onSelectDotcmsTab(): void { + this.activeTab.set('dotcms'); + } + + onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void { + this.dotcmsPageSize.set(event.rows); + this.fetchDotcmsVideosPage(event.first, event.rows); + } + + runDotcmsSearch(): void { + this.dotcmsFirst.set(0); + this.fetchDotcmsVideosPage(0, this.dotcmsPageSize()); + } + + private fetchDotcmsVideosPage(first: number, rows: number): void { + this.dotcmsLoading.set(true); + this.dotcmsError.set(null); + this.dotContentlet + .searchVideos({ + text: this.dotcmsSearchControl.getRawValue(), + offset: first, + limit: rows, + languageId: this.store.languageId() + }) + .pipe(take(1)) + .subscribe({ + next: ({ contentlets, totalRecords }) => { + this.zone.run(() => { + this.dotcmsVideos.set(contentlets); + this.dotcmsTotalRecords.set(totalRecords); + this.dotcmsFirst.set(first); + this.dotcmsLoading.set(false); + }); + }, + error: () => { + this.zone.run(() => { + this.dotcmsVideos.set([]); + this.dotcmsTotalRecords.set(0); + this.dotcmsError.set('Could not load videos from dotCMS.'); + this.dotcmsLoading.set(false); + }); + } + }); + } + + insertFromDotcms(contentlet: DotContentlet): void { + const src = `/dA/${contentlet.inode}`; + const title = contentlet.title || contentlet.identifier || undefined; + this.editor() + .chain() + .focus() + .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(); + } + + async onFileChange(event: Event): Promise { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + this.uploading.set(true); + try { + const { src, data } = await this.dotUpload.uploadVideo(file); + const title = file.name.replace(/\.[^.]+$/, ''); + this.zone.run(() => { + this.editor() + .chain() + .focus() + .insertContent({ + type: DOT_VIDEO_NODE_NAME, + attrs: { src, title: title ?? null, data } + }) + .run(); + this.manager.close(); + }); + } catch (err) { + console.error('Video upload failed', err); + } finally { + this.uploading.set(false); + } + } + + onInsertUrl(): void { + if (this.urlControl.invalid) return; + const url = this.urlControl.getRawValue(); + const title = this.titleControl.getRawValue().trim() || undefined; + const editor = this.editor(); + + // YouTube links use TipTap's youtube extension (renders an iframe embed). + // Anything else gets the dotVideo