From 4cbf147047b4ae06def107a3b2b9224f5d620014 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 16:47:14 +0000 Subject: [PATCH 1/3] feat(block-editor): inline contentlet reference (@-mention) end-to-end (#35473) Add a new inline atom node `dotInlineContent` that references a contentlet inline inside a paragraph (Notion-style @-mention). Only the reference ({identifier, languageId}) is stored; the title and front-end URL are resolved at render time, so renames/moves propagate automatically. Editor (new-block-editor): - New inline node extension + Angular node view (compact inline token, broken-reference fallback), modeled on the block `dotContent` node with the same skinny-ref serialization. - New `@`-mention Suggestion extension (separate plugin key) with a per-editor `InlineContentSuggestionService` doing debounced live title search via DotContentSearchService, plus a floating results component. - `buildContentletByTitleQuery` scopes the search to the content types allowed by the existing `contentTypes` field variable (empty => all). - Gated by the `dotInlineContent` allowed-block key; i18n + inline CSS. Backend (StoryBlock + VTL): - Add `dotInlineContent` to StoryBlockAPI.allowedTypes; existing recursive hydration reaches inline nodes nested in paragraphs unchanged. - render.vtl branch + new dotInlineContent.vtl emitting an inline (urlMap/url resolved via the $dotcontent viewtool) with a fallback. - Integration test for nested inline-content hydration. Headless SDKs: - Shared type: BlockEditorDefaultBlocks.DOT_INLINE_CONTENT. - React + Angular (legacy + semantic) renderers dispatch the new inline node to a default / component; customRenderers[node.type] override works with zero new API. README docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BUPpA53ghoRZ6AskMFbcVo --- core-web/libs/new-block-editor/CLAUDE.md | 4 +- .../inline-content-suggestion.component.ts | 174 ++++++++++++++++ .../slash-menu/slash-menu-catalog.ts | 45 +++- .../src/lib/editor/editor.component.css | 39 ++++ .../src/lib/editor/editor.component.ts | 8 + .../editor/extensions/editor-extensions.ts | 13 ++ .../inline-content-suggestion.extension.ts | 101 +++++++++ .../inline-content.component.ts | 67 ++++++ .../inline-content.extension.ts | 100 +++++++++ .../inline-content/inline-content.types.ts | 24 +++ .../inline-content-suggestion.service.ts | 196 ++++++++++++++++++ core-web/libs/sdk/angular/README.md | 4 + ...lock-editor-renderer-native.component.html | 4 + ...-block-editor-renderer-native.component.ts | 2 + .../blocks/inline-content.component.ts | 48 +++++ .../dotcms-block-editor-item.component.html | 4 + .../dotcms-block-editor-item.component.ts | 2 + core-web/libs/sdk/react/README.md | 16 ++ .../components/BlockEditorBlock.tsx | 4 + .../components/blocks/InlineContent.tsx | 40 ++++ .../block-editor-renderer/internal.ts | 2 + .../contenttype/business/StoryBlockAPI.java | 2 +- .../WEB-INF/messages/Language.properties | 8 + .../static/storyblock/dotInlineContent.vtl | 18 ++ .../velocity/static/storyblock/render.vtl | 7 + .../business/StoryBlockAPITest.java | 84 ++++++++ 26 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/inline-content-suggestion.extension.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.extension.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.types.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/inline-content-suggestion.service.ts create mode 100644 core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/inline-content.component.ts create mode 100644 core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/InlineContent.tsx create mode 100644 dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/dotInlineContent.vtl diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md index a2fee32d00a3..9b0cb00ead6d 100644 --- a/core-web/libs/new-block-editor/CLAUDE.md +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -55,6 +55,7 @@ When creating a new node, you may choose any name — but choose carefully, beca | Image | `dotImage` | `extensions/nodes/image.extension.ts` | | Video | `dotVideo` | `extensions/nodes/video.extension.ts` | | Contentlet | `dotContent` | `extensions/nodes/contentlet/contentlet.extension.ts` | +| Inline contentlet | `dotInlineContent` | `extensions/nodes/inline-content/inline-content.extension.ts` | | Grid block | `gridBlock` | `extensions/nodes/grid.extension.ts` | | Grid column | `gridColumn` | `extensions/nodes/grid.extension.ts` | | AI content | `aiContent` | `extensions/nodes/ai-content.extension.ts` | @@ -72,7 +73,7 @@ The lib follows a strict split: **data fetching** delegates to `@dotcms/data-acc | Service | Used for | |---|---| | `DotContentTypeService` | Content type filtering for the slash-menu's content-type sub-picker (`filterContentTypes`) and per-type metadata reads (`getContentType`, used by `ContentletEditUrlService`). | -| `DotContentSearchService` | Lucene search behind the slash-menu's contentlet drill-down (`/api/content/_search`). The editor-flavoured query string (`+contentType:X +languageId:Y +deleted:false +working:true +catchall:** title:''^15`) is built inline at the call site (`buildContentletByTypeQuery` in `slash-menu-catalog.ts`); the service itself stays generic. | +| `DotContentSearchService` | Lucene search behind the slash-menu's contentlet drill-down AND the inline `@`-mention picker (`/api/content/_search`). The editor-flavoured query strings (`buildContentletByTypeQuery` for the slash drill-down; `buildContentletByTitleQuery` for the inline `@`-search, which is title-scoped and honors the field's `contentTypes` allowlist) are built inline at the call sites in `slash-menu-catalog.ts`; the service itself stays generic. | | `DotLanguagesService` | Language metadata for the editor store (`getById`). | | `DotAiService` | AI text generation, AI image generation + publish, plugin status check. Identical surface to legacy block-editor usage. | | `DotUploadFileService` | Wrapped by the lib's local `DotUploadService` adapter (see below). | @@ -160,6 +161,7 @@ What actions are available on each node type. **Slash** = appears in `/` menu (` | `dotVideo` | `video.extension.ts` | Video (modal picker) | Insert video | `video` | | `youtube` | `@tiptap/extension-youtube` | — (legacy slash entry) | — | `youtube` | | `dotContent` | `contentlet/contentlet.extension.ts` | Content type → submenu | Edit contentlet (node-scoped) | `dotContent` | +| `dotInlineContent` | `inline-content/inline-content.extension.ts` | — (`@`-mention picker, inline) | — | `dotInlineContent` | | `gridBlock` | `grid.extension.ts` | Grid (2 columns) | — | `gridBlock` | | `gridColumn` | `grid.extension.ts` | — (created by `insertGrid`) | — | inherits gridBlock | | `aiContent` | `ai-content.extension.ts` | Ask AI (centered modal) | — | `aiContent` | diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts new file mode 100644 index 000000000000..74a32b8025a4 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts @@ -0,0 +1,174 @@ +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 { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { InlineContentSuggestionService } from '../../services/inline-content-suggestion.service'; + +/** + * Floating result list for the inline contentlet `@`-mention picker. Caret-anchored via + * `@floating-ui/dom`, driven by {@link InlineContentSuggestionService} signals. Mirrors the + * positioning / scroll-tracking approach of the slash menu, but lists async contentlet search + * results (title + content type) and handles the loading / empty states. + */ +@Component({ + selector: 'dot-inline-content-suggestion', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DotMessagePipe], + host: { + role: 'listbox', + '[attr.aria-label]': 'menuAriaLabel', + id: 'inline-content-suggestion-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: ` +
+ @if (service.isLoading()) { +
+ {{ 'dot.block.editor.suggestion.inline-content.loading' | dm }} +
+ } @else if (service.results().length === 0) { +
+

+ {{ 'dot.block.editor.suggestion.inline-content.empty.label' | dm }} +

+

+ {{ 'dot.block.editor.suggestion.inline-content.empty.description' | dm }} +

+
+ } @else { + @for (item of service.results(); track item.identifier; let i = $index) { + + } + } +
+ ` +}) +export class InlineContentSuggestionComponent { + protected readonly service = inject(InlineContentSuggestionService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly document = inject(DOCUMENT); + private readonly dotMessageService = inject(DotMessageService); + + protected readonly menuAriaLabel = this.dotMessageService.get( + 'dot.block.editor.suggestion.inline-content.aria-label' + ); + + protected onHostPointerDownCapture(): void { + this.service.prepareMenuPointerInteraction(); + } + + protected readonly floatX = signal(0); + protected readonly floatY = signal(0); + // Starts false on every open; prevents a 0,0 flash before computePosition resolves. + protected readonly positioned = signal(false); + private readonly scrollTick = signal(0); + + constructor() { + effect((onCleanup) => { + if (!this.service.isOpen()) return; + + const onScroll = () => this.scrollTick.update((n) => n + 1); + this.document.addEventListener('scroll', onScroll, { passive: true, capture: true }); + onCleanup(() => { + this.document.removeEventListener('scroll', onScroll, { capture: true }); + }); + }); + + // Keep the active option visible when arrow keys move the selection. + afterRenderEffect(() => { + if (!this.service.isOpen()) return; + const i = this.service.activeIndex(); + const target = this.el.nativeElement.querySelector( + `#inline-content-opt-${i}` + ) as HTMLElement | null; + target?.scrollIntoView({ block: 'nearest' }); + }); + + afterRenderEffect(() => { + this.scrollTick(); + const isOpen = this.service.isOpen(); + const clientRectFn = this.service.clientRectFn(); + + if (!isOpen || !clientRectFn) { + untracked(() => this.positioned.set(false)); + return; + } + + const virtualRef = { + getBoundingClientRect: () => clientRectFn() ?? new DOMRect() + }; + + 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); + }); + }); + }); + }); + } + + protected 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`; + } + + protected onMouseMove(i: number): void { + if (i !== this.service.activeIndex()) { + this.service.activeIndex.set(i); + } + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts index a4c4674d62f0..23bae3ddcfe4 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts @@ -49,7 +49,50 @@ function buildContentletByTypeQuery(variable: string, languageId: number): strin return `+contentType:${variable} +languageId:${languageId} +deleted:false +working:true +catchall:** title:''^15`; } -interface ContentletSearchEntity { +/** + * Strips Lucene special characters from a user-typed `@`-mention query so the term can be + * interpolated into a query string without breaking it or injecting clauses. Spaces collapse + * to single spaces; reserved syntax characters are removed. + */ +function sanitizeLuceneTerm(query: string): string { + return query + .replace(/[+\-!(){}[\]^"~*?:\\/]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Lucene query for the inline contentlet `@`-mention picker: live title search, optionally + * scoped to the content types allowed by the field's `contentTypes` field variable. + * + * @param query The user's `@`-mention text. Empty ⇒ browse recent (matches the slash picker's + * broad `+catchall:**` behaviour); non-empty ⇒ wildcard match on `catchall` with a strong + * `title` boost so exact-title hits float to the top. + * @param languageId Active editor language. + * @param allowedContentTypes Normalized comma-separated allowlist from `EditorStore` + * (e.g. `"Blog,News"`). Empty ⇒ no content-type restriction (search all types). + */ +function buildContentletByTitleQuery( + query: string, + languageId: number, + allowedContentTypes: string +): string { + const term = sanitizeLuceneTerm(query); + const titleClause = term + ? `+catchall:*${term}* title:'${term}'^15` + : `+catchall:** title:''^15`; + const types = allowedContentTypes + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + // Space inside the group is an implicit OR, e.g. `+contentType:(Blog News)`. + const typeClause = types.length > 0 ? `+contentType:(${types.join(' ')}) ` : ''; + return `${typeClause}+languageId:${languageId} +deleted:false +working:true ${titleClause}`; +} + +export { buildContentletByTitleQuery }; + +export interface ContentletSearchEntity { jsonObjectView?: { contentlets?: DotCMSContentlet[] }; } diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.css b/core-web/libs/new-block-editor/src/lib/editor/editor.component.css index 354b6060141d..0b595a7f3f9c 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/editor.component.css +++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.css @@ -781,3 +781,42 @@ background-color: rgba(99, 102, 241, 0.18); border-radius: 2px; } + +/* ─── Inline contentlet reference token (dotInlineContent node view) ─── */ +/* Compact, baseline-aligned token rendered within paragraph text by the `@`-mention picker. + The published / SDK output renders an
; in the editor it's a non-link selectable token. */ +:host ::ng-deep .ProseMirror dot-inline-content-node-view.dot-inline-content-token { + display: inline-flex; + align-items: baseline; + gap: 0.2em; + max-width: 100%; + padding: 0 0.3em; + border-radius: 0.25rem; + background-color: rgba(99, 102, 241, 0.12); + color: #4338ca; + line-height: inherit; + vertical-align: baseline; + white-space: nowrap; + cursor: pointer; +} + +:host ::ng-deep .ProseMirror dot-inline-content-node-view.dot-inline-content-token.is-selected { + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5); +} + +:host ::ng-deep .ProseMirror dot-inline-content-node-view.dot-inline-content-token.is-missing { + background-color: rgba(239, 68, 68, 0.12); + color: #b91c1c; + cursor: default; +} + +:host ::ng-deep .ProseMirror .dot-inline-content-token__icon { + font-size: 1em; + line-height: 1; + transform: translateY(0.12em); +} + +:host ::ng-deep .ProseMirror .dot-inline-content-token__label { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts index 78941b61da86..982c1e546247 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts @@ -32,6 +32,7 @@ import { DotMessagePipe } from '@dotcms/ui'; import { AssetByUrlPopoverComponent } from './components/asset-by-url-popover/asset-by-url-popover.component'; import { EmojiPickerComponent } from './components/emoji-picker/emoji-picker.component'; import { ImagePropertiesPopoverComponent } from './components/image-popover/image-popover.component'; +import { InlineContentSuggestionComponent } from './components/inline-content-suggestion/inline-content-suggestion.component'; import { LinkPopoverComponent } from './components/link-popover/link-popover.component'; import { SlashMenuComponent } from './components/slash-menu/slash-menu.component'; import { SlashMenuService } from './components/slash-menu/slash-menu.service'; @@ -50,6 +51,7 @@ import { ContentletEditUrlService } from './services/contentlet-edit-url.service import { DotUploadService } from './services/dot-upload.service'; import { EditorModalService } from './services/editor-modal.service'; import { EditorPopoverService } from './services/editor-popover.service'; +import { InlineContentSuggestionService } from './services/inline-content-suggestion.service'; import { EditorStore } from './store/editor.store'; import { loadRemoteExtensions, parseCustomBlocksField } from './utils/remote-extensions.loader'; @@ -177,6 +179,7 @@ function normalizeEditorContent( providers: [ EditorStore, SlashMenuService, + InlineContentSuggestionService, EditorPopoverService, EditorModalService, EditorToolbarStore, @@ -192,6 +195,7 @@ function normalizeEditorContent( imports: [ TiptapEditorDirective, SlashMenuComponent, + InlineContentSuggestionComponent, EmojiPickerComponent, TablePopoverComponent, TableHandlePopoverComponent, @@ -260,6 +264,7 @@ function normalizeEditorContent( + @@ -276,6 +281,8 @@ function normalizeEditorContent( export class DotCMSEditorComponent implements OnDestroy, ControlValueAccessor { /** Slash menu state; used by the template for ARIA on the ProseMirror surface. */ protected readonly menuService = inject(SlashMenuService); + /** Inline `@`-mention picker state; provided at component scope for per-editor isolation. */ + private readonly inlineSuggestionService = inject(InlineContentSuggestionService); /** Field-scoped UI state (e.g. allowed blocks, language for API calls). */ protected readonly store = inject(EditorStore); @@ -439,6 +446,7 @@ export class DotCMSEditorComponent implements OnDestroy, ControlValueAccessor { parseAllowedBlocks(this.field()), this.injector, this.dotMessageService, + this.inlineSuggestionService, remoteExtensions ), content: '' diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts index a4c4027d9fe5..b3361759ced9 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts @@ -16,12 +16,14 @@ import type { DotMessageService } from '@dotcms/data-access'; import { createBlockGutterDragHandle } from './block-gutter.extension'; import { IndentExtension } from './indent.extension'; +import { createInlineContentSuggestionExtension } from './inline-content-suggestion.extension'; import { DotLink } from './link.extension'; import { AIContent } from './nodes/ai-content.extension'; import { createCodeBlock } from './nodes/code-block/code-block.extension'; import { createDotContentlet } from './nodes/contentlet/contentlet.extension'; import { GridBlock, GridColumn } from './nodes/grid.extension'; import { DotImage } from './nodes/image.extension'; +import { createDotInlineContent } from './nodes/inline-content/inline-content.extension'; import { createUploadPlaceholderExtension, type UploadPlaceholderMediaType @@ -33,14 +35,17 @@ import { TableActiveCellsPlugin } from './table-active-cells.plugin'; import { createDotTableExtensions } from './table-extensions'; import { EditorPopoverService } from '../services/editor-popover.service'; +import { EditorStore } from '../store/editor.store'; import type { SlashMenuService } from '../components/slash-menu/slash-menu.service'; +import type { InlineContentSuggestionService } from '../services/inline-content-suggestion.service'; export function createEditorExtensions( menuService: SlashMenuService, allowedBlocks: string[] | undefined, injector: Injector, dotMessageService: DotMessageService, + inlineSuggestionService: InlineContentSuggestionService, remoteExtensions: AnyExtension[] = [] ): Extensions { const t = (key: string, ...args: string[]) => dotMessageService.get(key, ...args); @@ -135,6 +140,14 @@ export function createEditorExtensions( ] : []), ...(has('dotContent') ? [createDotContentlet(injector)] : []), + ...(has('dotInlineContent') + ? [ + createDotInlineContent(injector), + createInlineContentSuggestionExtension(inlineSuggestionService, () => + injector.get(EditorStore).languageId() + ) + ] + : []), ...(has('gridBlock') ? [GridBlock, GridColumn] : []), TextAlign.configure({ types: ['heading', 'paragraph'] }), IndentExtension, diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/inline-content-suggestion.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/inline-content-suggestion.extension.ts new file mode 100644 index 000000000000..15bf9e4f082d --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/inline-content-suggestion.extension.ts @@ -0,0 +1,101 @@ +import { Extension } from '@tiptap/core'; +import { PluginKey } from '@tiptap/pm/state'; +import Suggestion, { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; + +import type { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { DOT_INLINE_CONTENT_NODE_NAME } from './nodes/inline-content/inline-content.extension'; + +import type { InlineContentSuggestionService } from '../services/inline-content-suggestion.service'; + +/** + * Dedicated plugin key so this `@`-mention Suggestion plugin coexists with the slash-command + * Suggestion plugin (which uses the default `SuggestionPluginKey`) in the same editor. + */ +export const InlineContentSuggestionPluginKey = new PluginKey('inlineContentSuggestion'); + +/** + * `@`-mention extension for inline contentlet references. Mirrors the slash-command extension + * but uses a SEPARATE {@link Suggestion} instance (`char: '@'`) and an async, debounced live + * search owned by {@link InlineContentSuggestionService}. Selecting a result inserts a + * `dotInlineContent` node carrying the full contentlet; the node's `renderHTML` strips it to a + * skinny `{ identifier, languageId }` ref when the document is serialised for storage. + * + * Results are driven through the service (not TipTap's synchronous `items`), so `items` returns + * an empty array — the picker list and keyboard routing come from the service signals. + * + * @param service Per-editor picker state (provided at the editor-component scope). + * @param getLanguageId Active editor language, used as the inserted node's fallback `languageId`. + */ +export function createInlineContentSuggestionExtension( + service: InlineContentSuggestionService, + getLanguageId: () => number +) { + return Extension.create({ + name: 'inlineContentSuggestion', + + onDestroy() { + service.detachEditor(); + }, + + addProseMirrorPlugins() { + service.attachEditor(this.editor); + return [ + Suggestion({ + editor: this.editor, + pluginKey: InlineContentSuggestionPluginKey, + char: '@', + startOfLine: false, + allowSpaces: false, + + // Results are resolved asynchronously by the service; TipTap's synchronous + // item list is unused. + items: () => [], + + command: ({ editor, range, props }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: DOT_INLINE_CONTENT_NODE_NAME, + attrs: { + // Full contentlet at runtime; the JSON-strip helper reduces it + // to { identifier, languageId } when serialised for storage. + data: { + ...props, + languageId: + (props as { languageId?: number }).languageId ?? + getLanguageId() + } + } + }) + .run(); + }, + + render: () => ({ + onStart: (props: SuggestionProps) => { + service.open(props.query, props.clientRect ?? null, props.command); + }, + onUpdate: (props: SuggestionProps) => { + service.update(props.query, props.clientRect ?? null, props.command); + }, + onExit: (props: SuggestionProps) => { + // Suggestion fires onExit for moved-and-changed transitions while the + // match is still active; only tear down once the plugin deactivated. + const state = InlineContentSuggestionPluginKey.getState( + props.editor.state + ); + if (state?.active) { + return; + } + service.close(); + }, + onKeyDown: ({ event }: SuggestionKeyDownProps) => + service.handleKeyDown(event) + }) + }) + ]; + } + }); +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.component.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.component.ts new file mode 100644 index 000000000000..8972797ec17f --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.component.ts @@ -0,0 +1,67 @@ +import { AngularNodeViewComponent } from 'ngx-tiptap'; + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { type ContentletData, INLINE_CONTENT_HOST_CLASS } from './inline-content.types'; + +/** + * Inline node view for a contentlet reference. Renders a compact, baseline-aligned token + * (content-type icon + title) within the surrounding paragraph text — NOT the block card. + * + * Editing context only: the token is not a link here (clicking selects the node). The live + * `` to the contentlet's front-end URL is produced server-side (VTL) and by the headless + * SDK renderers. Broken references (missing `data` / deleted source) fall back to the last + * known title as a non-interactive "missing" token instead of throwing. + */ +@Component({ + selector: 'dot-inline-content-node-view', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: INLINE_CONTENT_HOST_CLASS, + '[attr.data-type]': "'dot-inline-content'", + '[class.is-selected]': 'selected()', + '[class.is-missing]': '!data()', + '[attr.data-identifier]': 'identifierAttr()', + '[attr.data-language-id]': 'languageIdAttr()', + '[attr.title]': 'hoverTitle()' + }, + template: ` + + {{ displayTitle() }} + ` +}) +export class DotInlineContentNodeViewComponent extends AngularNodeViewComponent { + private readonly dotMessageService = inject(DotMessageService); + + /** Shown when the reference can no longer be resolved (deleted/unavailable source). */ + protected readonly fallbackTitleLabel = this.dotMessageService.get( + 'dot.block.editor.contentlet.fallback-title' + ); + + protected readonly data = computed(() => this.node().attrs['data'] as ContentletData | null); + + protected readonly displayTitle = computed(() => { + const d = this.data(); + return d?.title || d?.identifier || this.fallbackTitleLabel; + }); + + /** Material Symbols ligature; `link_off` signals a broken/last-known reference. */ + protected readonly icon = computed(() => (this.data() ? 'article' : 'link_off')); + + protected readonly hoverTitle = computed(() => this.data()?.identifier ?? null); + + protected readonly identifierAttr = computed(() => { + const id = this.data()?.identifier; + return id ? String(id) : null; + }); + + protected readonly languageIdAttr = computed(() => { + const id = this.data()?.languageId; + return id != null ? String(id) : null; + }); +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.extension.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.extension.ts new file mode 100644 index 000000000000..ab0249dd3406 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.extension.ts @@ -0,0 +1,100 @@ +import { AngularNodeViewRenderer } from 'ngx-tiptap'; + +import type { Injector } from '@angular/core'; + +import { Node, mergeAttributes, type NodeViewRenderer } from '@tiptap/core'; + +import { DotInlineContentNodeViewComponent } from './inline-content.component'; +import { + type ContentletData, + type ContentletDataRef, + DOT_INLINE_CONTENT_NODE_NAME, + INLINE_CONTENT_HOST_CLASS, + INLINE_CONTENT_HTML_HOST_TAG +} from './inline-content.types'; + +export { + DOT_INLINE_CONTENT_NODE_NAME, + INLINE_CONTENT_HTML_HOST_TAG, + type ContentletData, + type ContentletDataRef +} from './inline-content.types'; + +/** + * Inline dotCMS contentlet reference. Lives inside a paragraph's content (Notion-style `@`-mention) + * and renders as a compact, linked token of the contentlet title. Canonical storage is ProseMirror + * JSON (`type: dotInlineContent`, `attrs.data`); HTML uses {@link INLINE_CONTENT_HTML_HOST_TAG} plus + * a `data` JSON attribute (skinny ref) for paste / export. The Angular node view is only for editing. + * + * Structurally identical to the block `dotContent` node (same `attrs.data` shape) so the backend's + * existing Story Block hydration machinery re-hydrates it unchanged — the difference is `inline: true`. + */ +export function createDotInlineContent(injector: Injector) { + return Node.create({ + name: DOT_INLINE_CONTENT_NODE_NAME, + inline: true, + group: 'inline', + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + data: { + default: null as ContentletData | null, + parseHTML: (element) => { + const raw = element.getAttribute('data'); + if (raw) { + try { + return JSON.parse(raw) as ContentletData; + } catch { + return null; + } + } + const identifier = element.getAttribute('data-identifier'); + if (!identifier) return null; + const languageIdRaw = element.getAttribute('data-language-id'); + return { + identifier, + languageId: languageIdRaw ? Number(languageIdRaw) : 1, + inode: element.getAttribute('data-inode') ?? undefined, + contentType: element.getAttribute('data-content-type') ?? undefined + } as ContentletData; + }, + renderHTML: (attrs) => { + if (!attrs['data']) return {}; + const skinny: ContentletDataRef = { + identifier: (attrs['data'] as ContentletData).identifier, + languageId: (attrs['data'] as ContentletData).languageId ?? 1 + }; + return { data: JSON.stringify(skinny) }; + } + } + }; + }, + + parseHTML() { + return [ + { tag: INLINE_CONTENT_HTML_HOST_TAG }, + { tag: 'span[data-type="dot-inline-content"]' } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + INLINE_CONTENT_HTML_HOST_TAG, + mergeAttributes( + { + 'data-type': 'dot-inline-content', + class: INLINE_CONTENT_HOST_CLASS + }, + HTMLAttributes + ) + ]; + }, + + addNodeView(): NodeViewRenderer { + return AngularNodeViewRenderer(DotInlineContentNodeViewComponent, { injector }); + } + }); +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.types.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.types.ts new file mode 100644 index 000000000000..3fb754e77800 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.types.ts @@ -0,0 +1,24 @@ +import type { ContentletData, ContentletDataRef } from '../contentlet/contentlet.types'; + +/** + * TipTap JSON `type` for an inline dotCMS contentlet reference (`@`-mention picker). + * + * IMMUTABLE: this string is persisted as the node `type` in customer documents. Renaming it + * would make TipTap drop stored inline references on load. See `new-block-editor/CLAUDE.md` + * ("TipTap Node Names Are Immutable"). + */ +export const DOT_INLINE_CONTENT_NODE_NAME = 'dotInlineContent' as const; + +/** + * HTML tag used in {@link renderHTML} / {@link parseHTML} for clipboard and server-side HTML. + * Live editing uses an Angular node view; persisted truth is ProseMirror JSON + * (`dotInlineContent` attrs). Inline counterpart to the block `dot-contentlet` tag. + */ +export const INLINE_CONTENT_HTML_HOST_TAG = 'dot-inline-content' as const; + +/** Host classes for the inline reference token (node view + static HTML export). */ +export const INLINE_CONTENT_HOST_CLASS = 'dot-inline-content-token' as const; + +// The inline reference reuses the block contentlet's attribute shape: full contentlet at +// runtime, skinny `{ identifier, languageId }` ref on disk (the backend re-hydrates the rest). +export type { ContentletData, ContentletDataRef }; diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/inline-content-suggestion.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/inline-content-suggestion.service.ts new file mode 100644 index 000000000000..a359216cedd6 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/services/inline-content-suggestion.service.ts @@ -0,0 +1,196 @@ +import { Subject, of } from 'rxjs'; + +import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { catchError, debounceTime, switchMap } from 'rxjs/operators'; + +import type { Editor } from '@tiptap/core'; + +import { DotContentSearchService } from '@dotcms/data-access'; +import type { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { + buildContentletByTitleQuery, + type ContentletSearchEntity +} from '../components/slash-menu/slash-menu-catalog'; +import { EditorStore } from '../store/editor.store'; + +/** Debounce window for the live `@`-mention search, in ms. */ +const SEARCH_DEBOUNCE_MS = 250; +/** Result cap for the picker. Mirrors the slash-menu contentlet drill-down. */ +const SEARCH_LIMIT = 40; + +/** + * Coordinates the inline contentlet `@`-mention picker for a single editor instance: the live + * title search (debounced), the floating result list, keyboard navigation, and editor focus so + * the `@tiptap/suggestion` session stays valid when picking from the overlay. + * + * Provided at the editor-component scope (next to {@link SlashMenuService}) so multiple editors + * on one page keep isolated picker state. Search delegates to {@link DotContentSearchService}; + * the query is scoped by the field's `contentTypes` allowlist via {@link EditorStore}. + */ +@Injectable() +export class InlineContentSuggestionService { + private readonly zone = inject(NgZone); + private readonly store = inject(EditorStore); + private readonly contentSearchService = inject(DotContentSearchService); + + /** Contentlet results currently shown in the picker. */ + readonly results = signal([]); + /** Whether the floating picker is visible. */ + readonly isOpen = signal(false); + /** True while a search request is in flight. */ + readonly isLoading = signal(false); + /** Index of the highlighted row for keyboard navigation. */ + readonly activeIndex = signal(0); + /** TipTap suggestion anchor: resolves the caret rect for positioning the overlay. */ + readonly clientRectFn = signal<(() => DOMRect | null) | null>(null); + /** Stable `id` for the active row, or `null` when closed/empty (a11y). */ + readonly activeOptionId = computed(() => + this.isOpen() && this.results().length > 0 + ? `inline-content-opt-${this.activeIndex()}` + : null + ); + + private commandFn: ((contentlet: DotCMSContentlet) => void) | null = null; + private editor: Editor | null = null; + private readonly query$ = new Subject(); + + constructor() { + this.query$ + .pipe( + debounceTime(SEARCH_DEBOUNCE_MS), + switchMap((query) => + this.contentSearchService + .get({ + query: buildContentletByTitleQuery( + query, + this.store.languageId(), + this.store.allowedContentTypes() + ), + sort: 'modDate desc', + offset: 0, + limit: SEARCH_LIMIT + }) + .pipe(catchError(() => of(null))) + ), + takeUntilDestroyed() + ) + .subscribe((entity) => { + const contentlets = entity?.jsonObjectView?.contentlets ?? []; + this.zone.run(() => { + this.results.set(contentlets); + this.activeIndex.set(0); + this.isLoading.set(false); + }); + }); + } + + /** Called by the suggestion extension so UI clicks can refocus the editor before commands run. */ + attachEditor(editor: Editor): void { + this.editor = editor; + } + + /** Clears the editor reference when the suggestion plugin is torn down. */ + detachEditor(): void { + this.editor = null; + } + + /** Focus the editor before a pointer interaction so the suggestion range isn't lost. */ + prepareMenuPointerInteraction(): void { + this.editor?.view.focus(); + } + + /** + * Opens the picker and kicks off the first (empty-query) search. + * + * @param query Initial `@`-mention text (usually empty on open). + * @param clientRectFn Anchor for overlay position; from TipTap suggestion props. + * @param commandFn Invoked with the chosen contentlet when the user confirms a row. + */ + open( + query: string, + clientRectFn: (() => DOMRect | null) | null, + commandFn: (contentlet: DotCMSContentlet) => void + ): void { + this.commandFn = commandFn; + this.zone.run(() => { + this.results.set([]); + this.clientRectFn.set(clientRectFn); + this.activeIndex.set(0); + this.isLoading.set(true); + this.isOpen.set(true); + }); + this.query$.next(query); + } + + /** + * Refreshes the query and/or anchor while a suggestion session is active. + * + * @param query Latest `@`-mention text. + * @param clientRectFn Updated caret rect. + * @param commandFn Latest TipTap command callback. + */ + update( + query: string, + clientRectFn: (() => DOMRect | null) | null, + commandFn: (contentlet: DotCMSContentlet) => void + ): void { + this.commandFn = commandFn; + this.zone.run(() => { + this.clientRectFn.set(clientRectFn); + this.isLoading.set(true); + }); + this.query$.next(query); + } + + /** Hides the picker and drops TipTap command wiring. */ + close(): void { + this.zone.run(() => { + this.isOpen.set(false); + this.clientRectFn.set(null); + this.commandFn = null; + this.isLoading.set(false); + this.results.set([]); + }); + } + + /** + * Confirms a row: refocuses the editor then runs the active command callback. + * Focusing first avoids losing the `@…` suggestion range when the overlay steals focus. + */ + select(contentlet: DotCMSContentlet): void { + this.editor?.view.focus(); + this.commandFn?.(contentlet); + } + + /** + * Handles arrow keys, Enter, and Escape while the picker is open. + * + * @returns `true` if the event was consumed and should not propagate. + */ + handleKeyDown(event: KeyboardEvent): boolean { + if (!this.isOpen()) return false; + const count = this.results().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.results()[this.activeIndex()]); + return true; + case 'Escape': + this.close(); + return true; + } + return false; + } +} diff --git a/core-web/libs/sdk/angular/README.md b/core-web/libs/sdk/angular/README.md index 7c02cb1269a1..a6a37c1b6a73 100644 --- a/core-web/libs/sdk/angular/README.md +++ b/core-web/libs/sdk/angular/README.md @@ -580,6 +580,10 @@ export class MyBannerComponent { } ``` +#### Inline contentlet references (`dotInlineContent`) + +The Block Editor can embed a **live, inline reference** to another contentlet inside a paragraph (authored with the `@`-mention picker). dotCMS stores only the reference (`{ identifier, languageId }`) and re-hydrates the current title at read time, so renames propagate automatically. By default the renderer outputs the title as an inline link (``) when a front-end URL is resolvable (`urlMap` for URL-mapped content, `url` for pages), and a plain inline label otherwise. Register a custom renderer for the `dotInlineContent` node type to override this output. + #### Recommendations - Should not be used with [`DotCMSEditableText`](#dotcmseditabletext). diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html index fe7be9ad1991..41a616d7fc12 100644 --- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html @@ -249,6 +249,10 @@

[customRenderers]="customRenderers()" /> } + @case (BLOCKS.DOT_INLINE_CONTENT) { + + } + @default { } diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.ts b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.ts index 8244919d4626..44eaf00b424f 100644 --- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.ts +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.ts @@ -16,6 +16,7 @@ import { } from './blocks/semantic-blocks.component'; import { DotContentletBlock } from '../dotcms-block-editor-renderer/blocks/dot-contentlet.component'; +import { DotInlineContentBlock } from '../dotcms-block-editor-renderer/blocks/inline-content.component'; import { DotUnknownBlockComponent } from '../dotcms-block-editor-renderer/blocks/unknown.component'; import { CustomRenderer } from '../dotcms-block-editor-renderer/dotcms-block-editor-renderer.component'; @@ -58,6 +59,7 @@ import { CustomRenderer } from '../dotcms-block-editor-renderer/dotcms-block-edi DotSemanticBlockQuote, DotSemanticCodeBlock, DotContentletBlock, + DotInlineContentBlock, DotUnknownBlockComponent ] }) diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/inline-content.component.ts b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/inline-content.component.ts new file mode 100644 index 000000000000..d3ff0b336824 --- /dev/null +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/inline-content.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, Input, computed } from '@angular/core'; + +import { BlockEditorNode } from '@dotcms/types'; + +/** + * Default renderer for an inline contentlet reference (`dotInlineContent`). + * + * Renders the referenced contentlet's title inline. When a front-end URL is available on the + * hydrated `attrs.data` (`urlMap` for URL-mapped content, or `url` for pages) it renders a link; + * otherwise it falls back to a plain inline label. Consumers can fully override this output by + * registering a custom renderer for the `dotInlineContent` node type — handled upstream by the + * `customRenderers[node.type]` lookup in the renderer templates. + */ +@Component({ + selector: 'dotcms-block-editor-renderer-inline-content', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if ($url(); as url) { + {{ $title() }} + } @else { + {{ $title() }} + } + ` +}) +export class DotInlineContentBlock { + @Input() node: BlockEditorNode | undefined; + + protected readonly $data = computed(() => this.node?.attrs?.['data']); + + protected readonly $title = computed(() => { + const data = this.$data(); + return data?.title || data?.identifier || ''; + }); + + protected readonly $url = computed(() => { + const data = this.$data(); + return data?.urlMap ?? data?.url ?? null; + }); + + private readonly DOT_INLINE_CONTENT_NO_DATA_MESSAGE = + '[DotCMSBlockEditorRenderer]: No data provided for an inline content reference (dotInlineContent). If the error persists, please contact the DotCMS support team.'; + + ngOnInit() { + if (!this.$data()) { + console.error(this.DOT_INLINE_CONTENT_NO_DATA_MESSAGE); + } + } +} diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html index 28cb63d9a2ae..d38c2df0d9e8 100644 --- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html @@ -101,6 +101,10 @@ [customRenderers]="customRenderers" /> } + @case (BLOCKS.DOT_INLINE_CONTENT) { + + } + @default { } diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.ts b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.ts index 0a7781d71b05..a45f1bc096f5 100644 --- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.ts +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.ts @@ -8,6 +8,7 @@ import { DotCodeBlock, DotBlockQuote } from '../blocks/code.component'; import { DotContentletBlock } from '../blocks/dot-contentlet.component'; import { DotGridBlock } from '../blocks/grid-block.component'; import { DotImageBlock } from '../blocks/image.component'; +import { DotInlineContentBlock } from '../blocks/inline-content.component'; import { DotBulletList, DotOrdererList, DotListItem } from '../blocks/list.component'; import { DotTableBlock } from '../blocks/table.component'; import { DotParagraphBlock, DotTextBlock, DotHeadingBlock } from '../blocks/text.component'; @@ -36,6 +37,7 @@ import { CustomRenderer } from '../dotcms-block-editor-renderer.component'; DotTableBlock, DotGridBlock, DotContentletBlock, + DotInlineContentBlock, DotUnknownBlockComponent ] }) diff --git a/core-web/libs/sdk/react/README.md b/core-web/libs/sdk/react/README.md index c44559cb8cbc..ec3ca0785659 100644 --- a/core-web/libs/sdk/react/README.md +++ b/core-web/libs/sdk/react/README.md @@ -377,6 +377,22 @@ const DetailPage = ({ contentlet }: { contentlet: DotCMSBasicContentlet }) => { }; ``` +#### Inline contentlet references (`dotInlineContent`) + +The Block Editor can embed a **live, inline reference** to another contentlet inside a paragraph (authored with the `@`-mention picker). dotCMS stores only the reference (`{ identifier, languageId }`) and re-hydrates the current title at read time, so renames propagate automatically. By default the renderer outputs the title as an inline link (``) when a front-end URL is resolvable (`urlMap` for URL-mapped content, `url` for pages), and a plain inline label otherwise. + +Override the default by registering a renderer for the `dotInlineContent` node type: + +```tsx +const CUSTOM_RENDERERS = { + dotInlineContent: ({ node }) => ( + + {node.attrs.data.title} + + ) +}; +``` + #### Next.js Server Components `DotCMSBlockEditorRenderer` can be used directly in a Next.js server component — it has no hooks or browser dependencies: diff --git a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx index 00ea6cb921f5..72ab73f0bc78 100644 --- a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx +++ b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx @@ -6,6 +6,7 @@ import { BlockQuote, CodeBlock } from './blocks/Code'; import { DotContent } from './blocks/DotContent'; import { GridBlock } from './blocks/GridBlock'; import { DotCMSImage } from './blocks/Image'; +import { InlineContent } from './blocks/InlineContent'; import { BulletList, ListItem, OrderedList } from './blocks/Lists'; import { TableRenderer } from './blocks/Table'; import { Heading, Paragraph, TextBlock } from './blocks/Texts'; @@ -174,6 +175,9 @@ export const BlockEditorBlock = ({ /> ); + case BlockEditorDefaultBlocks.DOT_INLINE_CONTENT: + return ; + default: return ; } diff --git a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/InlineContent.tsx b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/InlineContent.tsx new file mode 100644 index 000000000000..59b8e5e98bfc --- /dev/null +++ b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/InlineContent.tsx @@ -0,0 +1,40 @@ +import { BlockEditorNode } from '@dotcms/types'; + +interface InlineContentProps { + node: BlockEditorNode; +} + +const INLINE_CONTENT_NO_DATA_MESSAGE = + '[DotCMSBlockEditorRenderer]: No data provided for an inline content reference (dotInlineContent). If the error persists, please contact the DotCMS support team.'; + +/** + * Default renderer for an inline contentlet reference (`dotInlineContent`). + * + * Renders the referenced contentlet's title inline. When a front-end URL is available on the + * hydrated `attrs.data` (`urlMap` for URL-mapped content, or `url` for pages) it renders a link; + * otherwise it falls back to a plain inline label. Consumers can fully override this output by + * passing `customRenderers={{ dotInlineContent: MyComponent }}` — handled upstream in + * {@link BlockEditorBlock} by the `customRenderers[node.type]` lookup. + */ +export const InlineContent = ({ node }: InlineContentProps) => { + const data = node.attrs?.['data']; + + if (!data) { + console.error(INLINE_CONTENT_NO_DATA_MESSAGE); + + return null; + } + + const title = data.title || data.identifier || ''; + const url = data.urlMap ?? data.url ?? null; + + if (!url) { + return {title}; + } + + return ( + + {title} + + ); +}; diff --git a/core-web/libs/sdk/types/src/lib/components/block-editor-renderer/internal.ts b/core-web/libs/sdk/types/src/lib/components/block-editor-renderer/internal.ts index 0852123905f2..1bfa48ddf1da 100644 --- a/core-web/libs/sdk/types/src/lib/components/block-editor-renderer/internal.ts +++ b/core-web/libs/sdk/types/src/lib/components/block-editor-renderer/internal.ts @@ -33,6 +33,8 @@ export enum BlockEditorDefaultBlocks { TABLE = 'table', /** Represents a DotCMS content block */ DOT_CONTENT = 'dotContent', + /** Represents an inline DotCMS contentlet reference (rendered within a paragraph) */ + DOT_INLINE_CONTENT = 'dotInlineContent', /** Represents a grid block with columns */ GRID_BLOCK = 'gridBlock', /** Represents a column inside a grid block */ diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/StoryBlockAPI.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/StoryBlockAPI.java index f446bae5201d..57f4c8efd20f 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/StoryBlockAPI.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/StoryBlockAPI.java @@ -29,7 +29,7 @@ public interface StoryBlockAPI { /** * Contains the types of Contentlets that can be added to a Story Block field */ - Set allowedTypes = new ImmutableSet.Builder().add("dotContent", "dotImage", "dotVideo").build(); + Set allowedTypes = new ImmutableSet.Builder().add("dotContent", "dotImage", "dotVideo", "dotInlineContent").build(); /** * Updates all Contentlets referenced in every {@link com.dotcms.contenttype.model.field.StoryBlockField} of the diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 631f1393b2dc..e686a707b7f0 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6216,6 +6216,14 @@ dot.block.editor.slash-menu.ai-content.description=Generate text with AI dot.block.editor.slash-menu.ai-image.label=AI Image dot.block.editor.slash-menu.ai-image.description=Generate an image with AI +# Inline contentlet reference (@-mention picker) +dot.block.editor.suggestion.inline-content.aria-label=Inline content reference menu +dot.block.editor.suggestion.inline-content.loading=Searching… +dot.block.editor.suggestion.inline-content.empty.label=No content found +dot.block.editor.suggestion.inline-content.empty.description=Type to search content by title. +dot.block.editor.suggestion.inline-content.error.label=Could not load content +dot.block.editor.suggestion.inline-content.error.description=The request failed. Check your connection and try again. + # Block gutter dot.block.editor.gutter.add-block=Add block below, or open block menu on an empty line diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/dotInlineContent.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/dotInlineContent.vtl new file mode 100644 index 000000000000..cfa0698ab2c5 --- /dev/null +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/dotInlineContent.vtl @@ -0,0 +1,18 @@ +## Inline contentlet reference (dotInlineContent node). Renders a live to the referenced +## contentlet's front-end URL when one resolves (URL-mapped content via urlMap, or a page via url); +## otherwise a plain inline label. Title and URL come from the hydrated attrs.data, so renames and +## moves on the source contentlet propagate automatically. Output stays inline (no block wrapper) +## so it flows within the surrounding paragraph. +#set($inlineData = $!item.attrs.data) +#set($inlineTitle = "$!{inlineData.title}") +#if($inlineTitle == "")#set($inlineTitle = "$!{inlineData.identifier}")#end +#set($inlineInode = "$!{inlineData.inode}") +#set($inlineUrl = "") +#if($inlineInode != "") + #set($inlineRef = $dotcontent.find($inlineInode)) + #if($inlineRef) + #set($inlineUrl = "$!{inlineRef.urlMap}") + #if($inlineUrl == "")#set($inlineUrl = "$!{inlineRef.url}")#end + #end +#end +#if($inlineUrl != "")$esc.html($inlineTitle)#else$esc.html($inlineTitle)#end diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl index 455242a6f841..793f029f0655 100644 --- a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl @@ -76,6 +76,13 @@ *##set($item = $element)#* *##parse("static/storyblock/dotContent.vtl")#* *##end#* + *##elseif( $element.type == "dotInlineContent" )#* + *##if($dotStoryBlockRenderHelper)#* + *#$dotStoryBlockRenderHelper.render($element)#* + *##else#* + *##set($item = $element)#* + *##parse("static/storyblock/dotInlineContent.vtl")#* + *##end#* *##elseif( $element.type == "dotVideo" )#* *##if($dotStoryBlockRenderHelper)#* *#$dotStoryBlockRenderHelper.render($element)#* diff --git a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/StoryBlockAPITest.java b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/StoryBlockAPITest.java index 461b6cd4ac5a..f5f43307726b 100644 --- a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/StoryBlockAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/StoryBlockAPITest.java @@ -383,6 +383,90 @@ public void test_refresh_references() throws DotDataException, DotSecurityExcept } } + /** + * Method to test: {@link StoryBlockAPI#refreshStoryBlockValueReferences(Object, String)} + * Given Scenario: Creates a story block whose paragraph holds an inline contentlet reference + * ({@code dotInlineContent}) nested inside the paragraph's {@code content} array — alongside a + * text node — and then renames the referenced contentlet. + * ExpectedResult: Refreshing references descends into the paragraph and re-hydrates the nested + * inline node's {@code attrs.data.title} with the contentlet's current title. This proves the + * "live reference" promise for inline references. + */ + @Test + public void test_refresh_references_for_nested_inline_content() + throws DotDataException, DotSecurityException, JsonProcessingException { + // 1) create a contentlet to be referenced inline + final ContentType contentTypeRichText = APILocator.getContentTypeAPI(APILocator.systemUser()).find("webPageContent"); + final Contentlet referenced = new ContentletDataGen(contentTypeRichText) + .setProperty("title", "Inline Title 1") + .setProperty("body", TestDataUtils.BLOCK_EDITOR_DUMMY_CONTENT).nextPersisted(); + + // 2) build a story block with a dotInlineContent node nested inside a paragraph + final String storyBlockJson = String.format( + "{" + + " \"type\": \"doc\"," + + " \"content\": [" + + " {" + + " \"type\": \"paragraph\"," + + " \"content\": [" + + " { \"type\": \"text\", \"text\": \"See \" }," + + " {" + + " \"type\": \"dotInlineContent\"," + + " \"attrs\": {" + + " \"data\": {" + + " \"identifier\": \"%s\"," + + " \"languageId\": 1" + + " }" + + " }" + + " }" + + " ]" + + " }" + + " ]" + + "}", + referenced.getIdentifier()); + + // 3) rename the referenced contentlet so the stored (empty) title differs from the live one + final Contentlet newVersion = APILocator.getContentletAPI().checkout(referenced.getInode(), APILocator.systemUser(), false); + newVersion.setProperty("title", "Inline Title 2"); + newVersion.setProperty("body", TestDataUtils.BLOCK_EDITOR_DUMMY_CONTENT); + APILocator.getContentletAPI().publish( + APILocator.getContentletAPI().checkin(newVersion, APILocator.systemUser(), false), APILocator.systemUser(), false); + + final HttpServletRequest oldThreadRequest = HttpServletRequestThreadLocal.INSTANCE.getRequest(); + final HttpServletResponse oldThreadResponse = HttpServletResponseThreadLocal.INSTANCE.getResponse(); + + try { + HttpServletRequestThreadLocal.INSTANCE.setRequest(new MockAttributeRequest(mock(HttpServletRequest.class))); + HttpServletResponseThreadLocal.INSTANCE.setResponse(mock(HttpServletResponse.class)); + + // 4) refresh references — the nested inline node must be reached and hydrated + final StoryBlockReferenceResult refreshResult = + APILocator.getStoryBlockAPI().refreshStoryBlockValueReferences(storyBlockJson, "1234"); + + assertTrue("Nested inline reference should be refreshed", refreshResult.isRefreshed()); + + final Map refreshedMap = ContentletJsonHelper.INSTANCE.get().objectMapper() + .readValue(Try.of(() -> refreshResult.getValue().toString()).getOrElse(StringPool.BLANK), + LinkedHashMap.class); + final List docContent = (List) refreshedMap.get("content"); + final Map paragraph = (Map) docContent.get(0); + final List paragraphContent = (List) paragraph.get("content"); + final Optional inlineNode = paragraphContent.stream() + .filter(node -> "dotInlineContent".equals(Map.class.cast(node).get("type"))).findFirst(); + + assertTrue("Inline node should remain in the paragraph content", inlineNode.isPresent()); + final Map inlineData = (Map) Map.class.cast(Map.class.cast(inlineNode.get()).get(StoryBlockAPI.ATTRS_KEY)) + .get(StoryBlockAPI.DATA_KEY); + assertEquals("Inline reference identifier should be preserved", + referenced.getIdentifier(), inlineData.get("identifier")); + assertEquals("Inline reference title should be hydrated to the live title", + "Inline Title 2", inlineData.get("title")); + } finally { + HttpServletRequestThreadLocal.INSTANCE.setRequest(oldThreadRequest); + HttpServletResponseThreadLocal.INSTANCE.setResponse(oldThreadResponse); + } + } + /** * Method to test: {@link StoryBlockAPI#getDependencies(Object)} * Given Scenario: Creates a story block and adds 3 contentlets From b3b6adffeb9066a1c6d7e8ff6a03cbf3779a97b7 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Mon, 22 Jun 2026 14:28:00 -0400 Subject: [PATCH 2/3] fix(editor): resolve extension conflicts and enhance inline content suggestion error handling --- .../dot-block-editor.component.ts | 5 +++ .../inline-content-suggestion.component.ts | 13 ++++++-- .../src/lib/editor/editor.component.css | 2 ++ .../editor/extensions/editor-extensions.ts | 10 +++++- .../inline-content.component.ts | 13 ++++++-- .../inline-content-suggestion.service.ts | 32 ++++++++++++++----- 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts index e0ad344ee6db..9125a89d8494 100644 --- a/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts +++ b/core-web/libs/block-editor/src/lib/components/dot-block-editor/dot-block-editor.component.ts @@ -553,6 +553,11 @@ export class DotBlockEditorComponent implements OnInit, OnChanges, OnDestroy, Co return { heading: levels?.length ? { levels, HTMLAttributes: {} } : false, + // StarterKit v3 bundles Link + Underline, but this editor registers its own + // (`Underline` and `Link.extend(...)` in the extension list). Disable the bundled + // ones to avoid "Duplicate extension names found: ['link', 'underline']". + link: false, + underline: false, ...starterKit }; } diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts index 74a32b8025a4..1204caa8648f 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/components/inline-content-suggestion/inline-content-suggestion.component.ts @@ -48,6 +48,15 @@ import { InlineContentSuggestionService } from '../../services/inline-content-su
{{ 'dot.block.editor.suggestion.inline-content.loading' | dm }}
+ } @else if (service.hasError()) { +
+

+ {{ 'dot.block.editor.suggestion.inline-content.error.label' | dm }} +

+

+ {{ 'dot.block.editor.suggestion.inline-content.error.description' | dm }} +

+
} @else if (service.results().length === 0) {

@@ -67,9 +76,7 @@ import { InlineContentSuggestionService } from '../../services/inline-content-su [class]="itemClass(i)" (mousemove)="onMouseMove(i)" (click)="service.select(item)"> -