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/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html index bcfd61424449..aa0a295aa86f 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component.html @@ -12,15 +12,15 @@ } - @if (isNewBlockEditorEnabled()) { + + @if (isNewBlockEditorEnabled() === true) { - } @else { - + } @else if (isNewBlockEditorEnabled() === false) { + @if (service.isLoading()) { +
+ {{ '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) { +
+

+ {{ '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..3457a8766ec6 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(' OR ')}) ` : ''; + 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..03a3450d63aa 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,44 @@ 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; +} + +/* The content-type icon is always shown (a real Material Symbols glyph). Hovering the + token surfaces the content type name via the host's native `title` tooltip. */ +: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..2be016f6ed6d 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); @@ -78,7 +83,15 @@ export function createEditorExtensions( orderedList: has('orderedList') ? {} : false, blockquote: has('blockquote') ? {} : false, codeBlock: false, - horizontalRule: has('horizontalRule') ? {} : false + horizontalRule: has('horizontalRule') ? {} : false, + // StarterKit v3 bundles Link + Underline. Disable both here: + // - `link` is owned by our DotLink extension (added below, gated by `has('link')`). + // Leaving StarterKit's on causes a "Duplicate extension names: ['link']" warning + // and silently bypasses the allowedBlocks gating for links. + // - `underline` is not part of this editor's mark set, so we drop it rather than + // let the v3 upgrade smuggle it in. + link: false, + underline: false }), ...(has('codeBlock') ? [createCodeBlock(injector, lowlight)] : []), createBlockGutterDragHandle(t('dot.block.editor.gutter.add-block')), @@ -135,6 +148,21 @@ export function createEditorExtensions( ] : []), ...(has('dotContent') ? [createDotContentlet(injector)] : []), + // Register the inline-content NODE unconditionally so stored content always parses. + // TipTap's JSON loader (`schema.nodeFromJSON`) THROWS on an unknown node type and then + // resets the WHOLE document to empty — it cannot drop a single unknown JSON node the way + // the HTML parser can. So gating the node's schema registration by `allowedBlocks` would + // blank the entire field whenever a doc contains `dotInlineContent` but the field doesn't + // list it (e.g. tightened allowlist, paste, migration). Only the `@`-mention INSERTION is + // gated, so a restricted field still renders existing references but can't author new ones. + ...(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..81f260c64095 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/inline-content-suggestion.extension.ts @@ -0,0 +1,100 @@ +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..cb6714c603dc --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/inline-content/inline-content.component.ts @@ -0,0 +1,76 @@ +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. + * + * The content type name is surfaced via the host's native `title` attribute (a plain browser + * tooltip). We deliberately do NOT use PrimeNG `pTooltip` here: this element lives inside + * ProseMirror's `contenteditable` as a dynamically-rendered atom node view, where ProseMirror + * owns pointer interaction (it marks the node `contenteditable="false"` and intercepts mouse + * events for node selection), so the overlay's hover trigger is unreliable. The native tooltip + * needs no overlay/zone/positioning machinery and works in the web-component embedding and in + * fullscreen. + * + * 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')); + + /** Native tooltip on the token: the referenced content type's name. */ + protected readonly hoverTitle = computed(() => this.data()?.contentType ?? 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..a5f16a6f659f --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/services/inline-content-suggestion.service.ts @@ -0,0 +1,212 @@ +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); + /** True when the last search request failed (distinct from an empty result set). */ + readonly hasError = 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) => { + this.zone.run(() => { + // A request that resolves after the picker closed must not repopulate + // results or clear the loading flag for the next session. + if (!this.isOpen()) return; + // `catchError` maps a failed request to `null`; an empty result set is a + // non-null entity with no contentlets. Surface the two states distinctly. + if (entity === null) { + this.hasError.set(true); + this.results.set([]); + this.activeIndex.set(0); + this.isLoading.set(false); + return; + } + this.hasError.set(false); + this.results.set(entity?.jsonObjectView?.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.hasError.set(false); + 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.hasError.set(false); + 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.hasError.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': + // Nothing to navigate — let the event through rather than swallowing it. + if (count === 0) return false; + this.zone.run(() => this.activeIndex.update((i) => (i + 1) % count)); + return true; + case 'ArrowUp': + if (count === 0) return false; + this.zone.run(() => this.activeIndex.update((i) => (i - 1 + count) % 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