Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
</dot-card-field-label>
}
<dot-card-field-content>
@if (isNewBlockEditorEnabled()) {
<!-- Exclude null and undefined values from the template -->
@if (isNewBlockEditorEnabled() === true) {
<dot-block-editor
[languageId]="$languageId()"
[formControlName]="field.variable"
[contentlet]="$contentlet()"
[hasError]="fieldHasError"
[field]="field" />
} @else {
<!-- Default to the legacy editor whenever the flag is false OR still resolving — safer than rendering nothing. -->
} @else if (isNewBlockEditorEnabled() === false) {
<dot-old-block-editor
[languageId]="$languageId()"
[formControlName]="field.variable"
Expand Down
4 changes: 3 additions & 1 deletion core-web/libs/new-block-editor/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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). |
Expand Down Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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: `
<div class="max-h-72 overflow-y-auto p-1">
@if (service.isLoading()) {
<div class="px-2 py-3 text-sm text-gray-500">
{{ 'dot.block.editor.suggestion.inline-content.loading' | dm }}
</div>
} @else if (service.hasError()) {
<div class="px-2 py-3">
<p class="text-sm font-medium text-gray-700">
{{ 'dot.block.editor.suggestion.inline-content.error.label' | dm }}
</p>
<p class="text-xs text-gray-500">
{{ 'dot.block.editor.suggestion.inline-content.error.description' | dm }}
</p>
</div>
} @else if (service.results().length === 0) {
<div class="px-2 py-3">
<p class="text-sm font-medium text-gray-700">
{{ 'dot.block.editor.suggestion.inline-content.empty.label' | dm }}
</p>
<p class="text-xs text-gray-500">
{{ 'dot.block.editor.suggestion.inline-content.empty.description' | dm }}
</p>
</div>
} @else {
@for (item of service.results(); track item.identifier; let i = $index) {
<button
type="button"
[id]="'inline-content-opt-' + i"
role="option"
[attr.aria-selected]="service.activeIndex() === i"
[class]="itemClass(i)"
(mousemove)="onMouseMove(i)"
(click)="service.select(item)">
<span class="material-symbols-outlined text-gray-400" aria-hidden="true">
article
</span>
<span class="flex min-w-0 flex-col text-left">
<span class="truncate text-sm text-gray-900">
{{ item.title || item.identifier }}
</span>
<span class="truncate text-xs text-gray-500">
{{ item.contentType }}
</span>
</span>
</button>
}
}
</div>
`
})
export class InlineContentSuggestionComponent {
protected readonly service = inject(InlineContentSuggestionService);
private readonly el = inject(ElementRef<HTMLElement>);
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[] };
}

Expand Down
41 changes: 41 additions & 0 deletions core-web/libs/new-block-editor/src/lib/editor/editor.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a>; 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -177,6 +179,7 @@ function normalizeEditorContent(
providers: [
EditorStore,
SlashMenuService,
InlineContentSuggestionService,
EditorPopoverService,
EditorModalService,
EditorToolbarStore,
Expand All @@ -192,6 +195,7 @@ function normalizeEditorContent(
imports: [
TiptapEditorDirective,
SlashMenuComponent,
InlineContentSuggestionComponent,
EmojiPickerComponent,
TablePopoverComponent,
TableHandlePopoverComponent,
Expand Down Expand Up @@ -260,6 +264,7 @@ function normalizeEditorContent(
</div>

<dot-slash-menu />
<dot-inline-content-suggestion />
<dot-emoji-picker [editor]="ed" />
<dot-table-popover [editor]="ed" />
<dot-table-handle-popover [editor]="ed" />
Expand All @@ -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);
Expand Down Expand Up @@ -439,6 +446,7 @@ export class DotCMSEditorComponent implements OnDestroy, ControlValueAccessor {
parseAllowedBlocks(this.field()),
this.injector,
this.dotMessageService,
this.inlineSuggestionService,
remoteExtensions
),
content: ''
Expand Down
Loading
Loading