diff --git a/core-web/.claude/settings.json b/core-web/.claude/settings.json index a2fdc2bd4e9e..07cd2a069741 100644 --- a/core-web/.claude/settings.json +++ b/core-web/.claude/settings.json @@ -7,6 +7,9 @@ } } }, + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, "enabledPlugins": { "nx@nx-claude-plugins": true } diff --git a/core-web/apps/dotcms-block-editor/project.json b/core-web/apps/dotcms-block-editor/project.json index 2ac11fb1a1ed..7d197cac2f7a 100644 --- a/core-web/apps/dotcms-block-editor/project.json +++ b/core-web/apps/dotcms-block-editor/project.json @@ -7,13 +7,19 @@ "tags": ["skip:test", "skip:lint"], "targets": { "build": { - "executor": "@angular-devkit/build-angular:browser-esbuild", + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], "options": { - "outputPath": "dist/apps/dotcms-block-editor", + "baseHref": "./", + "outputPath": { + "base": "dist/apps/dotcms-block-editor", + "browser": "" + }, "index": "apps/dotcms-block-editor/src/index.html", - "main": "apps/dotcms-block-editor/src/main.ts", - "polyfills": "apps/dotcms-block-editor/src/polyfills.ts", + "browser": "apps/dotcms-block-editor/src/main.ts", + "polyfills": ["apps/dotcms-block-editor/src/polyfills.ts"], "tsConfig": "apps/dotcms-block-editor/tsconfig.app.json", + "inlineStyleLanguage": "css", "assets": [ "apps/dotcms-block-editor/src/favicon.ico", "apps/dotcms-block-editor/src/assets" @@ -33,24 +39,29 @@ "includePaths": ["libs/dotcms-scss/angular"] }, "allowedCommonJsDependencies": ["lodash.isequal", "date-fns"], - "vendorChunk": true, "extractLicenses": false, - "buildOptimizer": false, "sourceMap": true, "optimization": false, "namedChunks": true }, "configurations": { + "development": { + "optimization": false, + "sourceMap": true, + "namedChunks": true, + "extractLicenses": false + }, "localhost": { "sourceMap": true, - "optimization": false, - "watch": true + "optimization": false }, "tomcat": { - "outputPath": "../../tomcat9/webapps/ROOT/dotcms-block-editor", + "outputPath": { + "base": "../../tomcat9/webapps/ROOT/dotcms-block-editor", + "browser": "" + }, "sourceMap": true, - "optimization": false, - "watch": true + "optimization": false }, "production": { "fileReplacements": [ @@ -64,8 +75,6 @@ "sourceMap": false, "namedChunks": false, "extractLicenses": false, - "vendorChunk": false, - "buildOptimizer": true, "budgets": [ { "type": "initial", @@ -85,7 +94,7 @@ "serve": { "executor": "@angular/build:dev-server", "options": { - "buildTarget": "dotcms-block-editor:build" + "buildTarget": "dotcms-block-editor:build:development" }, "configurations": { "production": { @@ -113,7 +122,7 @@ "main": "apps/dotcms-block-editor/src/test.ts", "tsConfig": "apps/dotcms-block-editor/tsconfig.spec.json", "karmaConfig": "apps/dotcms-block-editor/karma.conf.js", - "polyfills": "apps/dotcms-block-editor/src/polyfills.ts", + "polyfills": ["apps/dotcms-block-editor/src/polyfills.ts"], "styles": [], "scripts": [], "assets": [] diff --git a/core-web/apps/dotcms-block-editor/src/app/app.component.ts b/core-web/apps/dotcms-block-editor/src/app/app.component.ts index 80797f2be3cd..3c85a604fcdb 100644 --- a/core-web/apps/dotcms-block-editor/src/app/app.component.ts +++ b/core-web/apps/dotcms-block-editor/src/app/app.component.ts @@ -1,11 +1,11 @@ import { Component } from '@angular/core'; +import { EditorComponent } from '@dotcms/new-block-editor'; @Component({ selector: 'dotcms-root', templateUrl: './app.component.html', styleUrls: [], - standalone: false + imports: [EditorComponent], + standalone: true }) -export class AppComponent { - title = 'dotcms-block-editor'; -} +export class AppComponent {} diff --git a/core-web/apps/dotcms-block-editor/src/app/app.config.ts b/core-web/apps/dotcms-block-editor/src/app/app.config.ts new file mode 100644 index 000000000000..b05ceb86b8b3 --- /dev/null +++ b/core-web/apps/dotcms-block-editor/src/app/app.config.ts @@ -0,0 +1,31 @@ +import Lara from '@primeuix/themes/lara'; + +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + +import { providePrimeNG } from 'primeng/config'; + +/** + * PrimeNG is required for components used inside `@dotcms/new-block-editor` (e.g. DataView in + * image/video dotCMS pickers). Theme + cssLayer order must match `apps/dotcms-block-editor/src/styles.css`. + */ +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideHttpClient(), + provideAnimationsAsync(), + providePrimeNG({ + theme: { + preset: Lara, + options: { + darkModeSelector: '.dark', + cssLayer: { + name: 'primeng', + order: 'tailwind-base, primeng, tailwind-utilities' + } + } + } + }) + ] +}; diff --git a/core-web/apps/dotcms-block-editor/src/app/app.module.ts b/core-web/apps/dotcms-block-editor/src/app/app.module.ts deleted file mode 100644 index 5db0bbd40f81..000000000000 --- a/core-web/apps/dotcms-block-editor/src/app/app.module.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; -import { DoBootstrap, Injector, NgModule } from '@angular/core'; -import { createCustomElement } from '@angular/elements'; -import { FormsModule } from '@angular/forms'; -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { ListboxModule } from 'primeng/listbox'; -import { OrderListModule } from 'primeng/orderlist'; - -import { BlockEditorModule, DotBlockEditorComponent } from '@dotcms/block-editor'; -import { - DotPropertiesService, - DotContentSearchService, - DotMessageService -} from '@dotcms/data-access'; -import { DotAssetSearchComponent, provideDotCMSTheme } from '@dotcms/ui'; - -import { AppComponent } from './app.component'; - -@NgModule({ - declarations: [AppComponent], - imports: [ - BrowserModule, - BrowserAnimationsModule, - CommonModule, - FormsModule, - BlockEditorModule, - OrderListModule, - ListboxModule, - HttpClientModule, - DotAssetSearchComponent - ], - providers: [ - DotPropertiesService, - DotContentSearchService, - DotMessageService, - provideDotCMSTheme() - ] -}) -export class AppModule implements DoBootstrap { - constructor(private injector: Injector) {} - - ngDoBootstrap() { - if (customElements.get('dotcms-block-editor') === undefined) { - const element = createCustomElement(DotBlockEditorComponent, { - injector: this.injector - }); - customElements.define('dotcms-block-editor', element); - } - } -} diff --git a/core-web/apps/dotcms-block-editor/src/index.html b/core-web/apps/dotcms-block-editor/src/index.html index dce10144bf60..4869c64eb278 100644 --- a/core-web/apps/dotcms-block-editor/src/index.html +++ b/core-web/apps/dotcms-block-editor/src/index.html @@ -3,9 +3,14 @@ DotBlockEditor - + + + + diff --git a/core-web/apps/dotcms-block-editor/src/main.ts b/core-web/apps/dotcms-block-editor/src/main.ts index 207c6dde9399..6049f18db0b7 100644 --- a/core-web/apps/dotcms-block-editor/src/main.ts +++ b/core-web/apps/dotcms-block-editor/src/main.ts @@ -1,13 +1,12 @@ import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { bootstrapApplication } from '@angular/platform-browser'; -import { AppModule } from './app/app.module'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; import { environment } from './environments/environment'; if (environment.production) { enableProdMode(); } -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch((err) => console.error(err)); +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); diff --git a/core-web/apps/dotcms-block-editor/src/styles.css b/core-web/apps/dotcms-block-editor/src/styles.css index 19318a93cbfd..f433b088eb1b 100644 --- a/core-web/apps/dotcms-block-editor/src/styles.css +++ b/core-web/apps/dotcms-block-editor/src/styles.css @@ -1,7 +1,219 @@ +/* You can add global styles to this file, and also import other style files */ + +@layer tailwind-base, primeng, tailwind-utilities; + @import 'tailwindcss'; -@import 'tailwindcss-primeui'; +@plugin "@tailwindcss/typography"; + +/* ─── Material Symbols (Outlined) ──────────────────────── */ +/* Font loaded from Google Fonts in index.html */ +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 18px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-variation-settings: + 'FILL' 0, + 'wght' 300, + 'GRAD' -25, + 'opsz' 20; +} + +.tiptap { + @apply px-16 py-8; +} + +/* ─── Drag handle ──────────────────────────────────────── */ +/* The extension manages show/hide via element.style.visibility — do NOT use opacity here */ +.drag-handle-wrapper { + display: flex; + align-items: center; + gap: 2px; + z-index: 20; +} + +.drag-handle, +.add-block-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + color: #9ca3af; + transition: + color 150ms ease, + background-color 150ms ease; +} + +.drag-handle { + cursor: grab; +} + +.add-block-btn { + cursor: pointer; + border: none; + background: transparent; + padding: 0; +} + +.drag-handle:hover, +.add-block-btn:hover { + color: #374151; + background-color: #f3f4f6; +} + +.drag-handle:active { + cursor: grabbing; + background-color: #e5e7eb; +} + +.add-block-btn:active { + background-color: #e5e7eb; +} + +/* ─── Table ─────────────────────────────────────────────── */ +.tiptap table { + border-collapse: collapse; + width: 100%; + table-layout: fixed; + overflow: hidden; + margin: 0; +} + +.tiptap td, +.tiptap th { + border: 1px solid #e5e7eb; + padding: 8px 12px; + vertical-align: top; + position: relative; + min-width: 80px; +} + +.tiptap th { + background-color: #f9fafb; + font-weight: 600; + text-align: left; +} + +.tiptap .selectedCell { + background-color: #eff6ff; +} + +.tiptap .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + background-color: #6366f1; + cursor: col-resize; + pointer-events: all; +} + +.tiptap .tableWrapper { + overflow-x: auto; +} + +/* ─── Placeholders ──────────────────────────────────────── */ +.tiptap p.is-empty::before, +.tiptap h1.is-empty::before, +.tiptap h2.is-empty::before, +.tiptap h3.is-empty::before, +.tiptap h4.is-empty::before, +.tiptap blockquote p.is-empty::before { + content: attr(data-placeholder); + color: #9ca3af; + pointer-events: none; + float: left; + height: 0; +} + +/* ─── Links ─────────────────────────────────────────────── */ +.tiptap a { + color: #6366f1; + text-decoration: underline; + cursor: pointer; +} + +.tiptap a:hover { + color: #4f46e5; +} + +.tiptap a.link-editing { + background-color: rgba(99, 102, 241, 0.12); + border-radius: 2px; + outline: 2px solid rgba(99, 102, 241, 0.3); + outline-offset: 1px; +} + +/* ─── Upload placeholder ─────────────────────────────── */ +.upload-placeholder { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + border-radius: 0.5rem; + background-color: #f9fafb; + border: 1.5px dashed #d1d5db; + color: #6b7280; + margin: 0.25rem 0; + user-select: none; + cursor: default; +} + +.upload-placeholder__icon { + font-size: 1.25rem; + color: #9ca3af; + flex-shrink: 0; +} + +.upload-placeholder__label { + font-size: 0.875rem; + flex: 1; +} + +.upload-placeholder__bar { + width: 100px; + height: 4px; + border-radius: 9999px; + background-color: #e5e7eb; + overflow: hidden; + flex-shrink: 0; + position: relative; +} + +.upload-placeholder__bar::after { + content: ''; + position: absolute; + inset: 0; + background-color: #6366f1; + border-radius: 9999px; + animation: upload-sweep 1.4s ease-in-out infinite; + transform-origin: left center; +} -.p-dialog-mask.p-component-overlay.p-dialog-mask-scrollblocker { - background-color: transparent; - backdrop-filter: none; +@keyframes upload-sweep { + 0% { + transform: translateX(-100%) scaleX(0.4); + } + 50% { + transform: translateX(60%) scaleX(0.6); + } + 100% { + transform: translateX(200%) scaleX(0.4); + } } diff --git a/core-web/apps/dotcms-block-editor/tsconfig.app.json b/core-web/apps/dotcms-block-editor/tsconfig.app.json index 9c2690370c4e..78e91a9a7b3b 100644 --- a/core-web/apps/dotcms-block-editor/tsconfig.app.json +++ b/core-web/apps/dotcms-block-editor/tsconfig.app.json @@ -5,7 +5,11 @@ "types": [], "target": "ES2022", "useDefineForClassFields": false, - "moduleResolution": "bundler" + "module": "preserve", + "moduleResolution": "bundler", + "resolvePackageJsonExports": true, + "incremental": true, + "esModuleInterop": true }, "files": ["src/main.ts", "src/polyfills.ts"], "exclude": ["**/*.stories.ts", "**/*.stories.js"] diff --git a/core-web/libs/dotcms-scss/angular/styles.scss b/core-web/libs/dotcms-scss/angular/styles.scss index 455383dfebb0..c21c6800b0f0 100644 --- a/core-web/libs/dotcms-scss/angular/styles.scss +++ b/core-web/libs/dotcms-scss/angular/styles.scss @@ -131,14 +131,6 @@ However this is for the dragula we use in the angular components the one in the user-select: none !important; } -code { - color: $color-accessible-text-purple !important; - background-color: $color-accessible-text-purple-bg; - padding: $spacing-0 $spacing-1; - font-family: $font-code; - line-break: anywhere; -} - .dot-mask { background-color: transparent; backdrop-filter: none; diff --git a/core-web/libs/new-block-editor/.eslintrc.json b/core-web/libs/new-block-editor/.eslintrc.json new file mode 100644 index 000000000000..66e6eb7f6d0c --- /dev/null +++ b/core-web/libs/new-block-editor/.eslintrc.json @@ -0,0 +1,38 @@ +{ + "extends": ["../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "dot", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "dot", + "style": "kebab-case" + } + ], + "@angular-eslint/prefer-standalone": "off", + "@angular-eslint/no-input-rename": "off" + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md new file mode 100644 index 000000000000..2d0bb6952e34 --- /dev/null +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -0,0 +1,39 @@ +## Interaction Preferences + +Act with constructive skepticism. You are a collaborator with strong reasoning ability. + +Make decisions based on evidence. Do not assume you must agree with me. + +You should: + +- Question weak premises +- Point out flaws in reasoning +- Propose new approaches or mental models + +If I am approaching a problem from the wrong perspective or with incorrect assumptions, explain it clearly and suggest a better starting point. + +Be direct. +Avoid unnecessary validation language, emojis, or marketing tone. + +## Expected Response Format + +Your responses should focus on: + +- **Core insight** +- **Key tradeoffs** +- **Major risks** +- **Recommended next move** + +## Deferred Refactors + +### Floating dialog abstraction +All three block dialogs (table, image, video) duplicate the same component-level logic: +- `floatX`, `floatY`, `positioned` signals +- `effect((onCleanup))` for document-level Escape + click-outside dismiss +- `afterRenderEffect` with `computePosition(flip(), shift())` for positioning + +And the same service-level pattern: +- `isOpen` + `clientRectFn` signals +- `zone.run()` wrapping in `open()` / `close()` + +**Trigger:** Extract into a `FloatingPanelDirective` + generic base service when a 4th block type with a dialog is added, or when the duplication actively causes a bug/inconsistency. Not worth doing at 3 blocks. \ No newline at end of file diff --git a/core-web/libs/new-block-editor/jest.config.ts b/core-web/libs/new-block-editor/jest.config.ts new file mode 100644 index 000000000000..01bf9067c12c --- /dev/null +++ b/core-web/libs/new-block-editor/jest.config.ts @@ -0,0 +1,25 @@ +/* eslint-disable */ +export default { + displayName: 'new-block-editor', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: {}, + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: [ + 'node_modules/(?!.*\\.mjs$|.*(y-protocols|lib0|y-prosemirror|@tiptap|marked))' + ], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ], + coveragePathIgnorePatterns: ['node_modules/'] +}; diff --git a/core-web/libs/new-block-editor/project.json b/core-web/libs/new-block-editor/project.json new file mode 100644 index 000000000000..ca9d09a6feff --- /dev/null +++ b/core-web/libs/new-block-editor/project.json @@ -0,0 +1,23 @@ +{ + "name": "new-block-editor", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/new-block-editor/src", + "prefix": "dotcms", + "tags": ["type:feature", "scope:new-block-editor"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/new-block-editor/jest.config.ts", + "verbose": false, + "tsConfig": "libs/new-block-editor/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/core-web/libs/new-block-editor/src/feature.md b/core-web/libs/new-block-editor/src/feature.md new file mode 100644 index 000000000000..d7fef684c3b3 --- /dev/null +++ b/core-web/libs/new-block-editor/src/feature.md @@ -0,0 +1,12 @@ +### Features + +1. Convert the block editor component into a form-friendly component using `ControlValueAccessor` +2. Add a **Grid block** that allows users to: + - Create columns + - Resize them + + Two references for this behavior: + - Local: `/Users/rjvelazco/Desktop/dotcms/core/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/GridBlock.tsx` + - Remote: https://github.com/hunghg255/reactjs-tiptap-editor/tree/main/src/extensions/Column +3. In the **Link form**, add a checkbox to let the user toggle whether the link should open in a new tab (`target="_blank"`) +4. Add a **selected state style** for the following node types: images, videos, and contentlets \ No newline at end of file diff --git a/core-web/libs/new-block-editor/src/index.ts b/core-web/libs/new-block-editor/src/index.ts new file mode 100644 index 000000000000..aac7aa758c6a --- /dev/null +++ b/core-web/libs/new-block-editor/src/index.ts @@ -0,0 +1,5 @@ +/** + * Experimental block editor playground — add feature exports from `lib/` as you refactor. + */ + +export * from './lib/editor/editor.component'; diff --git a/core-web/libs/new-block-editor/src/lib/app.config.ts b/core-web/libs/new-block-editor/src/lib/app.config.ts new file mode 100644 index 000000000000..052a99121608 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.config.ts @@ -0,0 +1,29 @@ +import Lara from '@primeuix/themes/lara'; + +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { providePrimeNG } from 'primeng/config'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideHttpClient(), + provideRouter(routes), + providePrimeNG({ + theme: { + preset: Lara, + options: { + darkModeSelector: '.dark', + cssLayer: { + name: 'primeng', + order: 'tailwind-base, primeng, tailwind-utilities' + } + } + } + }) + ] +}; diff --git a/core-web/libs/new-block-editor/src/lib/app.routes.ts b/core-web/libs/new-block-editor/src/lib/app.routes.ts new file mode 100644 index 000000000000..dc39edb5f23a --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/core-web/libs/new-block-editor/src/lib/app.spec.ts b/core-web/libs/new-block-editor/src/lib/app.spec.ts new file mode 100644 index 000000000000..dd4e6efa7ac7 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.spec.ts @@ -0,0 +1,24 @@ +import { TestBed } from '@angular/core/testing'; + +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', async () => { + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, block-editor'); + }); +}); diff --git a/core-web/libs/new-block-editor/src/lib/app.ts b/core-web/libs/new-block-editor/src/lib/app.ts new file mode 100644 index 000000000000..51b15a5a65ad --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/app.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { EditorComponent } from './editor/editor.component'; + +@Component({ + selector: 'dot-block-editor-root', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [EditorComponent], + template: ` + + ` +}) +export class App {} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts b/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts new file mode 100644 index 000000000000..04a2bad34bc2 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/floating-block-dialog.base.ts @@ -0,0 +1,28 @@ +import { NgZone, inject, signal } from '@angular/core'; + +/** + * Shared open/close + signals for floating block insert dialogs. + * Subclasses own insert callbacks and any extra state (e.g. initialValues). + */ +export abstract class FloatingBlockDialogService { + protected readonly zone = inject(NgZone); + + readonly isOpen = signal(false); + readonly clientRectFn = signal<(() => DOMRect | null) | null>(null); + + protected openFloating(clientRectFn: () => DOMRect | null, arm: () => void): void { + this.zone.run(() => { + arm(); + this.clientRectFn.set(clientRectFn); + this.isOpen.set(true); + }); + } + + protected closeFloating(disarm: () => void): void { + this.zone.run(() => { + disarm(); + this.isOpen.set(false); + this.clientRectFn.set(null); + }); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts new file mode 100644 index 000000000000..6bf70a8f3473 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.component.ts @@ -0,0 +1,569 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + computed, + effect, + inject, + signal, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview'; + +import { take } from 'rxjs/operators'; + +import { ImageDialogService } from './image-dialog.service'; + +import { + DotCmsContentletService, + type DotCmsContentlet +} from '../../services/dot-cms-contentlet.service'; +import { DotCmsUploadService } from '../../services/dot-cms-upload.service'; +import { DOT_CMS_BASE_URL } from '../../services/dot-cms.config'; + +type Tab = 'upload' | 'url' | 'dotcms'; + +@Component({ + selector: 'dot-block-editor-image-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, DataViewModule], + host: { + '[attr.aria-label]': 'isEditing() ? "Edit image" : "Insert image"', + class: 'absolute z-50 w-[32rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg', + '[style.display]': 'service.isOpen() ? null : "none"', + '[style.visibility]': 'positioned() ? "visible" : "hidden"', + '[style.left.px]': 'floatX()', + '[style.top.px]': 'floatY()' + }, + template: ` + @if (isEditing()) { + +
+
+ + +
+ +
+ +

+ Text shown when hovering over the image +

+ +
+ +
+ +

+ Read aloud by screen readers; improves accessibility +

+ +
+ +
+ + +
+
+ } @else { + +
+ + + +
+ + @if (activeTab() === 'upload') { +
+ +
+ } + + @if (activeTab() === 'url') { +
+ + +
+ +
+
+ } + + @if (activeTab() === 'dotcms') { +
+
+ +
+ + +
+
+ + @if (dotcmsError()) { + + } @else { +
+ + +
+ @for (img of items; track img.inode) { + + } +
+
+
+
+ } +
+ } + } + ` +}) +export class ImageDialogComponent { + protected readonly service = inject(ImageDialogService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly document = inject(DOCUMENT); + private readonly dotCmsUpload = inject(DotCmsUploadService); + private readonly dotCmsContentlet = inject(DotCmsContentletService); + + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + protected readonly positioned = signal(false); + protected readonly activeTab = signal('url'); + protected readonly isEditing = computed(() => this.service.initialValues() !== null); + protected readonly uploading = signal(false); + protected readonly dotcmsImages = signal([]); + protected readonly dotcmsLoading = signal(false); + protected readonly dotcmsError = signal(null); + protected readonly dotcmsTotalRecords = signal(0); + protected readonly dotcmsFirst = signal(0); + /** Last page size from DataView (rows per page); kept for “Search” reset. */ + protected readonly dotcmsPageSize = signal(8); + readonly dotcmsRows = 8; + readonly dotcmsRowsOptions: number[] = [8, 16, 24]; + + private previouslyFocused: HTMLElement | null = null; + + readonly urlControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }); + + readonly dotcmsSearchControl = new FormControl('', { nonNullable: true }); + + readonly editForm = new FormGroup({ + src: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + title: new FormControl('', { nonNullable: true }), + alt: new FormControl('', { nonNullable: true }) + }); + + constructor() { + effect(() => { + const values = this.service.initialValues(); + if (values) { + untracked(() => + this.editForm.setValue({ + src: values.src, + title: values.title, + alt: values.alt + }) + ); + } + }); + + effect((onCleanup) => { + if (!this.service.isOpen()) return; + + this.previouslyFocused = this.document.activeElement as HTMLElement | null; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') this.zone.run(() => this.service.close()); + }; + const handleMouseDown = (event: MouseEvent) => { + if (!this.el.nativeElement.contains(event.target as Node)) { + this.zone.run(() => this.service.close()); + } + }; + + this.document.addEventListener('keydown', handleKeyDown); + this.document.addEventListener('mousedown', handleMouseDown); + onCleanup(() => { + this.document.removeEventListener('keydown', handleKeyDown); + this.document.removeEventListener('mousedown', handleMouseDown); + this.previouslyFocused?.focus({ preventScroll: true }); + this.previouslyFocused = null; + }); + }); + + afterRenderEffect(() => { + const isOpen = this.service.isOpen(); + const clientRectFn = this.service.clientRectFn(); + + if (!isOpen || !clientRectFn) { + untracked(() => { + this.positioned.set(false); + this.activeTab.set('url'); + this.urlControl.reset(''); + this.dotcmsSearchControl.reset(''); + this.dotcmsImages.set([]); + this.dotcmsError.set(null); + this.dotcmsLoading.set(false); + this.dotcmsTotalRecords.set(0); + this.dotcmsFirst.set(0); + this.dotcmsPageSize.set(this.dotcmsRows); + this.editForm.reset({ src: '', title: '', alt: '' }); + }); + return; + } + + const virtualRef = { + getBoundingClientRect: () => clientRectFn() ?? new DOMRect() + }; + + computePosition(virtualRef, this.el.nativeElement, { + placement: 'bottom-start', + strategy: 'absolute', + middleware: [flip(), shift({ padding: 8 })] + }).then(({ x, y }) => { + this.zone.run(() => { + untracked(() => { + this.floatX.set(x); + this.floatY.set(y); + this.positioned.set(true); + }); + }); + setTimeout(() => { + const firstInput = this.el.nativeElement.querySelector( + 'input:not([type="file"]):not([type="checkbox"])' + ) as HTMLElement | null; + firstInput?.focus(); + }, 0); + }); + }); + } + + tabClass(tab: Tab): string { + const base = + 'flex min-w-0 flex-1 items-center justify-center gap-1.5 px-2 py-2.5 text-xs font-medium border-b-2 transition-colors sm:gap-2 sm:px-3 sm:text-sm'; + return this.activeTab() === tab + ? `${base} border-indigo-500 text-indigo-600 bg-white` + : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`; + } + + dotcmsThumbUrl(inode: string): string { + return `${DOT_CMS_BASE_URL}/dA/${inode}/120/max`; + } + + onSelectDotcmsTab(): void { + this.activeTab.set('dotcms'); + } + + onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void { + this.dotcmsPageSize.set(event.rows); + this.fetchDotcmsImagesPage(event.first, event.rows); + } + + /** New search/filter: reset to first page (keeps current rows-per-page). */ + runDotcmsSearch(): void { + this.dotcmsFirst.set(0); + this.fetchDotcmsImagesPage(0, this.dotcmsPageSize()); + } + + private fetchDotcmsImagesPage(first: number, rows: number): void { + this.dotcmsLoading.set(true); + this.dotcmsError.set(null); + this.dotCmsContentlet + .searchImages({ + text: this.dotcmsSearchControl.getRawValue(), + offset: first, + limit: rows + }) + .pipe(take(1)) + .subscribe({ + next: ({ contentlets, totalRecords }) => { + this.zone.run(() => { + this.dotcmsImages.set(contentlets); + this.dotcmsTotalRecords.set(totalRecords); + this.dotcmsFirst.set(first); + this.dotcmsLoading.set(false); + }); + }, + error: () => { + this.zone.run(() => { + this.dotcmsImages.set([]); + this.dotcmsTotalRecords.set(0); + this.dotcmsError.set('Could not load images from dotCMS.'); + this.dotcmsLoading.set(false); + }); + } + }); + } + + insertFromDotcms(contentlet: DotCmsContentlet): void { + const src = `${DOT_CMS_BASE_URL}/dA/${contentlet.inode}`; + const label = contentlet.title || contentlet.identifier; + this.service.insert(src, label || undefined, label || undefined); + } + + async onFileChange(event: Event): Promise { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + this.uploading.set(true); + try { + const src = await this.dotCmsUpload.uploadImage(file); + this.zone.run(() => this.service.insert(src, undefined, file.name)); + } catch (err) { + console.error('Image upload failed', err); + } finally { + this.uploading.set(false); + } + } + + onInsertUrl(): void { + if (this.urlControl.invalid) return; + this.service.insert(this.urlControl.getRawValue()); + } + + onApplyEdit(): void { + if (this.editForm.controls.src.invalid) return; + const { src, title, alt } = this.editForm.getRawValue(); + this.service.insert(src, title || undefined, alt || undefined); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts new file mode 100644 index 000000000000..a6cbdf3fea80 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/image/image-dialog.service.ts @@ -0,0 +1,41 @@ +import { Injectable, signal } from '@angular/core'; + +import { FloatingBlockDialogService } from '../floating-block-dialog.base'; + +export type InsertImageFn = (src: string, title?: string, alt?: string) => void; + +export interface ImageInitialValues { + src: string; + title: string; + alt: string; +} + +@Injectable({ providedIn: 'root' }) +export class ImageDialogService extends FloatingBlockDialogService { + readonly initialValues = signal(null); + + private insertFn: InsertImageFn | null = null; + + open( + insertFn: InsertImageFn, + clientRectFn: () => DOMRect | null, + initialValues?: ImageInitialValues + ): void { + this.openFloating(clientRectFn, () => { + this.insertFn = insertFn; + this.initialValues.set(initialValues ?? null); + }); + } + + insert(src: string, title?: string, alt?: string): void { + this.insertFn?.(src, title, alt); + this.close(); + } + + close(): void { + this.closeFloating(() => { + this.initialValues.set(null); + this.insertFn = null; + }); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts new file mode 100644 index 000000000000..26a3594e370e --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.component.ts @@ -0,0 +1,195 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + computed, + effect, + inject, + signal, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { LinkDialogService } from './link-dialog.service'; + +@Component({ + selector: 'dot-block-editor-link-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule], + host: { + '[attr.aria-label]': 'isEditing() ? "Edit link" : "Insert link"', + class: 'absolute z-50 w-80 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg', + '[style.display]': 'service.isOpen() ? null : "none"', + '[style.visibility]': 'positioned() ? "visible" : "hidden"', + '[style.left.px]': 'floatX()', + '[style.top.px]': 'floatY()' + }, + template: ` +
+

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

+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+
+ ` +}) +export class LinkDialogComponent { + protected readonly service = inject(LinkDialogService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly document = inject(DOCUMENT); + + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + protected readonly positioned = signal(false); + protected readonly isEditing = computed(() => this.service.initialValues() !== null); + + private previouslyFocused: HTMLElement | null = null; + + readonly form = new FormGroup({ + href: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }), + displayText: new FormControl('', { nonNullable: true }), + openInNewTab: new FormControl(false, { nonNullable: true }) + }); + + constructor() { + // Pre-populate form when opened in edit mode + effect(() => { + const values = this.service.initialValues(); + untracked(() => { + if (values) { + this.form.setValue({ + href: values.href, + displayText: values.displayText, + openInNewTab: values.target === '_blank' + }); + } + }); + }); + + effect((onCleanup) => { + if (!this.service.isOpen()) return; + + this.previouslyFocused = this.document.activeElement as HTMLElement | null; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') this.zone.run(() => this.service.close()); + }; + const handleMouseDown = (event: MouseEvent) => { + if (!this.el.nativeElement.contains(event.target as Node)) { + this.zone.run(() => this.service.close()); + } + }; + + this.document.addEventListener('keydown', handleKeyDown); + this.document.addEventListener('mousedown', handleMouseDown); + onCleanup(() => { + this.document.removeEventListener('keydown', handleKeyDown); + this.document.removeEventListener('mousedown', handleMouseDown); + this.previouslyFocused?.focus({ preventScroll: true }); + this.previouslyFocused = null; + }); + }); + + afterRenderEffect(() => { + const isOpen = this.service.isOpen(); + const clientRectFn = this.service.clientRectFn(); + + if (!isOpen || !clientRectFn) { + untracked(() => { + this.positioned.set(false); + this.form.reset({ href: '', displayText: '', openInNewTab: false }); + }); + return; + } + + const virtualRef = { + getBoundingClientRect: () => clientRectFn() ?? new DOMRect() + }; + + computePosition(virtualRef, this.el.nativeElement, { + placement: 'bottom-start', + strategy: 'absolute', + middleware: [flip(), shift({ padding: 8 })] + }).then(({ x, y }) => { + this.zone.run(() => { + untracked(() => { + this.floatX.set(x); + this.floatY.set(y); + this.positioned.set(true); + }); + }); + setTimeout(() => { + const firstInput = this.el.nativeElement.querySelector( + 'input:not([type="file"]):not([type="checkbox"])' + ) as HTMLElement | null; + firstInput?.focus(); + }, 0); + }); + }); + } + + onInsert(): void { + if (this.form.controls.href.invalid) return; + const { href, displayText, openInNewTab } = this.form.getRawValue(); + this.service.insert(href, displayText.trim() || undefined, openInNewTab); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts new file mode 100644 index 000000000000..acb9b5b6377a --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/link/link-dialog.service.ts @@ -0,0 +1,55 @@ +import { Injectable, signal } from '@angular/core'; + +import { FloatingBlockDialogService } from '../floating-block-dialog.base'; + +export type InsertLinkFn = (href: string, displayText?: string, openInNewTab?: boolean) => void; + +export interface LinkInitialValues { + href: string; + displayText: string; + target?: string | null; +} + +@Injectable({ providedIn: 'root' }) +export class LinkDialogService extends FloatingBlockDialogService { + readonly initialValues = signal(null); + + private insertFn: InsertLinkFn | null = null; + private activeLinkEl: HTMLElement | null = null; + + open( + insertFn: InsertLinkFn, + clientRectFn: () => DOMRect | null, + initialValues?: { href?: string; displayText?: string; target?: string | null }, + linkEl?: HTMLElement + ): void { + this.openFloating(clientRectFn, () => { + this.insertFn = insertFn; + this.initialValues.set( + initialValues + ? { + href: initialValues.href ?? '', + displayText: initialValues.displayText ?? '', + target: initialValues.target ?? null + } + : null + ); + this.activeLinkEl = linkEl ?? null; + this.activeLinkEl?.classList.add('link-editing'); + }); + } + + insert(href: string, displayText?: string, openInNewTab?: boolean): void { + this.insertFn?.(href, displayText, openInNewTab); + this.close(); + } + + close(): void { + this.closeFloating(() => { + this.activeLinkEl?.classList.remove('link-editing'); + this.activeLinkEl = null; + this.initialValues.set(null); + this.insertFn = null; + }); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts new file mode 100644 index 000000000000..26a1ea9c4ec3 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.component.ts @@ -0,0 +1,191 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + effect, + inject, + signal, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { TableDialogService } from './table-dialog.service'; + +const DEFAULT_ROWS = 3; +const DEFAULT_COLS = 3; +const MAX_VALUE = 20; + +@Component({ + selector: 'dot-block-editor-table-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule], + host: { + 'aria-label': 'Insert table', + class: 'absolute z-50 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg', + '[style.display]': 'service.isOpen() ? null : "none"', + '[style.visibility]': 'positioned() ? "visible" : "hidden"', + '[style.left.px]': 'floatX()', + '[style.top.px]': 'floatY()' + }, + template: ` +
+

+ Insert Table +

+ +
+
+ + +
+
+ + +
+
+ + + +
+ + +
+
+ ` +}) +export class TableDialogComponent { + protected readonly service = inject(TableDialogService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly document = inject(DOCUMENT); + + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + protected readonly positioned = signal(false); + protected readonly maxValue = MAX_VALUE; + + private previouslyFocused: HTMLElement | null = null; + + readonly form = new FormGroup({ + rows: new FormControl(DEFAULT_ROWS, { + nonNullable: true, + validators: [Validators.required, Validators.min(1), Validators.max(MAX_VALUE)] + }), + cols: new FormControl(DEFAULT_COLS, { + nonNullable: true, + validators: [Validators.required, Validators.min(1), Validators.max(MAX_VALUE)] + }), + withHeaderRow: new FormControl(true, { nonNullable: true }) + }); + + constructor() { + effect((onCleanup) => { + if (!this.service.isOpen()) return; + + this.previouslyFocused = this.document.activeElement as HTMLElement | null; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') this.zone.run(() => this.service.close()); + }; + const handleMouseDown = (event: MouseEvent) => { + if (!this.el.nativeElement.contains(event.target as Node)) { + this.zone.run(() => this.service.close()); + } + }; + + this.document.addEventListener('keydown', handleKeyDown); + this.document.addEventListener('mousedown', handleMouseDown); + onCleanup(() => { + this.document.removeEventListener('keydown', handleKeyDown); + this.document.removeEventListener('mousedown', handleMouseDown); + this.previouslyFocused?.focus({ preventScroll: true }); + this.previouslyFocused = null; + }); + }); + + afterRenderEffect(() => { + const isOpen = this.service.isOpen(); + const clientRectFn = this.service.clientRectFn(); + + if (!isOpen || !clientRectFn) { + untracked(() => { + this.positioned.set(false); + this.form.reset({ + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + withHeaderRow: true + }); + }); + return; + } + + const virtualRef = { + getBoundingClientRect: () => clientRectFn() ?? new DOMRect() + }; + + computePosition(virtualRef, this.el.nativeElement, { + placement: 'bottom-start', + strategy: 'absolute', + middleware: [flip(), shift({ padding: 8 })] + }).then(({ x, y }) => { + this.zone.run(() => { + untracked(() => { + this.floatX.set(x); + this.floatY.set(y); + this.positioned.set(true); + }); + }); + setTimeout(() => { + const firstInput = this.el.nativeElement.querySelector( + 'input:not([type="file"]):not([type="checkbox"])' + ) as HTMLElement | null; + firstInput?.focus(); + }, 0); + }); + }); + } + + onApply(): void { + if (this.form.invalid) return; + this.service.insert(this.form.getRawValue()); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts new file mode 100644 index 000000000000..a96b88bb0414 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table/table-dialog.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; + +import { FloatingBlockDialogService } from '../floating-block-dialog.base'; + +export interface TableConfig { + rows: number; + cols: number; + withHeaderRow: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class TableDialogService extends FloatingBlockDialogService { + private insertFn: ((config: TableConfig) => void) | null = null; + + open(insertFn: (config: TableConfig) => void, clientRectFn: () => DOMRect | null): void { + this.openFloating(clientRectFn, () => { + this.insertFn = insertFn; + }); + } + + /** Commits the table dimensions and closes the dialog (same contract as other block dialogs’ `insert`). */ + insert(config: TableConfig): void { + this.insertFn?.(config); + this.close(); + } + + close(): void { + this.closeFloating(() => { + this.insertFn = null; + }); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts new file mode 100644 index 000000000000..48327325c672 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.component.ts @@ -0,0 +1,484 @@ +import { computePosition, flip, shift } from '@floating-ui/dom'; + +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterRenderEffect, + effect, + inject, + signal, + untracked +} from '@angular/core'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { DataViewModule, type DataViewLazyLoadEvent } from 'primeng/dataview'; + +import { take } from 'rxjs/operators'; + +import { VideoDialogService } from './video-dialog.service'; + +import { + DotCmsContentletService, + type DotCmsContentlet +} from '../../services/dot-cms-contentlet.service'; +import { DotCmsUploadService } from '../../services/dot-cms-upload.service'; +import { DOT_CMS_BASE_URL } from '../../services/dot-cms.config'; + +type Tab = 'upload' | 'url' | 'dotcms'; + +@Component({ + selector: 'dot-block-editor-video-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, DataViewModule], + host: { + 'aria-label': 'Insert video', + class: 'absolute z-50 w-[32rem] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg', + '[style.display]': 'service.isOpen() ? null : "none"', + '[style.visibility]': 'positioned() ? "visible" : "hidden"', + '[style.left.px]': 'floatX()', + '[style.top.px]': 'floatY()' + }, + template: ` + +
+ + + +
+ + + @if (activeTab() === 'upload') { +
+ +
+ } + + + @if (activeTab() === 'url') { +
+
+ + +
+
+ + +
+
+ +
+
+ } + + @if (activeTab() === 'dotcms') { +
+
+ +
+ + +
+
+ + @if (dotcmsError()) { + + } @else { +
+ + +
+ @for (vid of items; track vid.inode) { + + } +
+
+
+
+ } +
+ } + ` +}) +export class VideoDialogComponent { + protected readonly service = inject(VideoDialogService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly document = inject(DOCUMENT); + private readonly dotCmsUpload = inject(DotCmsUploadService); + private readonly dotCmsContentlet = inject(DotCmsContentletService); + + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + protected readonly positioned = signal(false); + protected readonly activeTab = signal('url'); + protected readonly uploading = signal(false); + protected readonly dotcmsVideos = signal([]); + protected readonly dotcmsLoading = signal(false); + protected readonly dotcmsError = signal(null); + protected readonly dotcmsTotalRecords = signal(0); + protected readonly dotcmsFirst = signal(0); + protected readonly dotcmsPageSize = signal(8); + readonly dotcmsRows = 8; + readonly dotcmsRowsOptions: number[] = [8, 16, 24]; + + private previouslyFocused: HTMLElement | null = null; + + readonly urlControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s]+/)] + }); + + readonly titleControl = new FormControl('', { nonNullable: true }); + + readonly dotcmsSearchControl = new FormControl('', { nonNullable: true }); + + constructor() { + effect((onCleanup) => { + if (!this.service.isOpen()) return; + + this.previouslyFocused = this.document.activeElement as HTMLElement | null; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') this.zone.run(() => this.service.close()); + }; + const handleMouseDown = (event: MouseEvent) => { + if (!this.el.nativeElement.contains(event.target as Node)) { + this.zone.run(() => this.service.close()); + } + }; + + this.document.addEventListener('keydown', handleKeyDown); + this.document.addEventListener('mousedown', handleMouseDown); + onCleanup(() => { + this.document.removeEventListener('keydown', handleKeyDown); + this.document.removeEventListener('mousedown', handleMouseDown); + this.previouslyFocused?.focus({ preventScroll: true }); + this.previouslyFocused = null; + }); + }); + + afterRenderEffect(() => { + const isOpen = this.service.isOpen(); + const clientRectFn = this.service.clientRectFn(); + + if (!isOpen || !clientRectFn) { + untracked(() => { + this.positioned.set(false); + this.activeTab.set('url'); + this.urlControl.reset(''); + this.titleControl.reset(''); + this.dotcmsSearchControl.reset(''); + this.dotcmsVideos.set([]); + this.dotcmsError.set(null); + this.dotcmsLoading.set(false); + this.dotcmsTotalRecords.set(0); + this.dotcmsFirst.set(0); + this.dotcmsPageSize.set(this.dotcmsRows); + }); + return; + } + + const virtualRef = { + getBoundingClientRect: () => clientRectFn() ?? new DOMRect() + }; + + computePosition(virtualRef, this.el.nativeElement, { + placement: 'bottom-start', + strategy: 'absolute', + middleware: [flip(), shift({ padding: 8 })] + }).then(({ x, y }) => { + this.zone.run(() => { + untracked(() => { + this.floatX.set(x); + this.floatY.set(y); + this.positioned.set(true); + }); + }); + setTimeout(() => { + const firstInput = this.el.nativeElement.querySelector( + 'input:not([type="file"]):not([type="checkbox"])' + ) as HTMLElement | null; + firstInput?.focus(); + }, 0); + }); + }); + } + + tabClass(tab: Tab): string { + const base = + 'flex min-w-0 flex-1 items-center justify-center gap-1.5 px-2 py-2.5 text-xs font-medium border-b-2 transition-colors sm:gap-2 sm:px-3 sm:text-sm'; + return this.activeTab() === tab + ? `${base} border-indigo-500 text-indigo-600 bg-white` + : `${base} border-transparent text-gray-500 hover:text-gray-700 bg-gray-50`; + } + + /** Full asset URL; avoid image resize filters — they may not apply to video binaries. */ + dotcmsVideoPreviewUrl(inode: string): string { + return `${DOT_CMS_BASE_URL}/dA/${inode}`; + } + + onSelectDotcmsTab(): void { + this.activeTab.set('dotcms'); + } + + onDotcmsLazyLoad(event: DataViewLazyLoadEvent): void { + this.dotcmsPageSize.set(event.rows); + this.fetchDotcmsVideosPage(event.first, event.rows); + } + + runDotcmsSearch(): void { + this.dotcmsFirst.set(0); + this.fetchDotcmsVideosPage(0, this.dotcmsPageSize()); + } + + private fetchDotcmsVideosPage(first: number, rows: number): void { + this.dotcmsLoading.set(true); + this.dotcmsError.set(null); + this.dotCmsContentlet + .searchVideos({ + text: this.dotcmsSearchControl.getRawValue(), + offset: first, + limit: rows + }) + .pipe(take(1)) + .subscribe({ + next: ({ contentlets, totalRecords }) => { + this.zone.run(() => { + this.dotcmsVideos.set(contentlets); + this.dotcmsTotalRecords.set(totalRecords); + this.dotcmsFirst.set(first); + this.dotcmsLoading.set(false); + }); + }, + error: () => { + this.zone.run(() => { + this.dotcmsVideos.set([]); + this.dotcmsTotalRecords.set(0); + this.dotcmsError.set('Could not load videos from dotCMS.'); + this.dotcmsLoading.set(false); + }); + } + }); + } + + insertFromDotcms(contentlet: DotCmsContentlet): void { + const src = `${DOT_CMS_BASE_URL}/dA/${contentlet.inode}`; + const title = contentlet.title || contentlet.identifier || undefined; + this.service.insert(src, title); + } + + async onFileChange(event: Event): Promise { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + this.uploading.set(true); + try { + const src = await this.dotCmsUpload.uploadVideo(file); + const title = file.name.replace(/\.[^.]+$/, ''); + this.zone.run(() => this.service.insert(src, title)); + } catch (err) { + console.error('Video upload failed', err); + } finally { + this.uploading.set(false); + } + } + + onInsertUrl(): void { + if (this.urlControl.invalid) return; + const title = this.titleControl.getRawValue().trim() || undefined; + this.service.insert(this.urlControl.getRawValue(), title); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts new file mode 100644 index 000000000000..c152f365ebae --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/video/video-dialog.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; + +import { FloatingBlockDialogService } from '../floating-block-dialog.base'; + +export type InsertVideoFn = (src: string, title?: string) => void; + +@Injectable({ providedIn: 'root' }) +export class VideoDialogService extends FloatingBlockDialogService { + private insertFn: InsertVideoFn | null = null; + + open(insertFn: InsertVideoFn, clientRectFn: () => DOMRect | null): void { + this.openFloating(clientRectFn, () => { + this.insertFn = insertFn; + }); + } + + insert(src: string, title?: string): void { + this.insertFn?.(src, title); + this.close(); + } + + close(): void { + this.closeFloating(() => { + this.insertFn = null; + }); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts b/core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts new file mode 100644 index 000000000000..eb8db0ea58f6 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/editor-character-stats.ts @@ -0,0 +1,24 @@ +import type { WritableSignal } from '@angular/core'; + +import type { Editor } from '@tiptap/core'; + +export interface CharacterStatsSignals { + wordCount: WritableSignal; + charCount: WritableSignal; + readingTime: WritableSignal; +} + +/** Reads TipTap CharacterCount extension storage and updates UI signals. */ +export function syncCharacterStatsFromEditor(editor: Editor, signals: CharacterStatsSignals): void { + const storage = editor.storage as { + characterCount?: { words: () => number; characters: () => number }; + }; + const cc = storage.characterCount; + if (!cc) return; + + const words = cc.words(); + const chars = cc.characters(); + signals.wordCount.set(words); + signals.charCount.set(chars); + signals.readingTime.set(Math.max(1, Math.ceil(words / 200))); +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts b/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts new file mode 100644 index 000000000000..32092f7fb3a2 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts @@ -0,0 +1,86 @@ +import type { Editor } from '@tiptap/core'; + +import type { ImageDialogService } from './components/image/image-dialog.service'; +import type { LinkDialogService } from './components/link/link-dialog.service'; + +/** + * Handles clicks on rich content inside ProseMirror (image / link edit dialogs). + * Kept outside the component to keep EditorComponent focused on lifecycle and wiring. + */ +export function handleEditorProseMirrorClick( + event: MouseEvent, + editor: Editor, + _imageDialog: ImageDialogService, + linkDialog: LinkDialogService +): void { + // TODO: Image click-to-edit disabled — use the toolbar "Edit image properties" button instead. + // const img = (event.target as HTMLElement).closest('img') as HTMLImageElement | null; + // if (img) { + // const src = img.getAttribute('src') ?? ''; + // const title = img.getAttribute('title') ?? ''; + // const alt = img.getAttribute('alt') ?? ''; + // const rect = img.getBoundingClientRect(); + // + // let imgPos: number; + // try { + // imgPos = editor.view.posAtDOM(img, 0); + // } catch { + // return; + // } + // + // event.preventDefault(); + // + // imageDialog.open( + // (newSrc, newTitle, newAlt) => { + // editor + // .chain() + // .focus() + // .setNodeSelection(imgPos) + // .updateAttributes('image', { + // src: newSrc, + // title: newTitle || null, + // alt: newAlt || null + // }) + // .run(); + // }, + // () => rect, + // { src, title, alt } + // ); + // return; + // } + + const anchor = (event.target as HTMLElement).closest('a[href]'); + if (!anchor) return; + + const href = anchor.getAttribute('href') ?? ''; + const displayText = anchor.textContent?.trim() ?? ''; + const rect = anchor.getBoundingClientRect(); + + let anchorPos: number; + try { + anchorPos = editor.view.posAtDOM(anchor, 0); + } catch { + anchorPos = editor.state.selection.from; + } + + event.preventDefault(); + + linkDialog.open( + (newHref, newDisplayText) => { + editor + .chain() + .focus() + .setTextSelection(anchorPos) + .extendMarkRange('link') + .insertContent({ + type: 'text', + text: newDisplayText ?? newHref, + marks: [{ type: 'link', attrs: { href: newHref } }] + }) + .run(); + }, + () => rect, + { href, displayText }, + anchor as HTMLElement + ); +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts b/core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts new file mode 100644 index 000000000000..395f571c43f6 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/editor-demo-content.ts @@ -0,0 +1,41 @@ +/** Default HTML shown when the editor loads (demo / onboarding). */ +export const EDITOR_DEMO_CONTENT = ` +

Block Editor

+

Welcome! Type / anywhere to open the block menu and insert content.

+ +

Text blocks

+

Regular paragraph text. You can write bold, italic, and inline code.

+

A blockquote stands out from the rest of the content — great for callouts or citations.

+
const greet = (name: string) => \`Hello, \${name}!\`;
+console.log(greet('World'));
+ +

Lists

+
    +
  • Bullet item one
  • +
  • Bullet item two
  • +
  • Bullet item three
  • +
+
    +
  1. First ordered item
  2. +
  3. Second ordered item
  4. +
  5. Third ordered item
  6. +
+ +

Links

+

Click once to select a link, double-click to edit it. Try it: Tiptap docs or Angular docs. You can also paste a URL directly and it will auto-link.

+ +

Table

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